Compare commits

..

8 Commits

Author SHA1 Message Date
Kit Langton
8f39c4bfff Merge branch 'dev' into kit/skill-lazy-init 2026-03-20 09:17:34 -04:00
Kit Langton
67b1d7ef36 Merge branch 'dev' into kit/skill-lazy-init 2026-03-19 21:16:40 -04:00
Kit Langton
07cdb2668c Merge remote-tracking branch 'origin/dev' into kit/skill-lazy-init
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/opencode/src/skill/skill.ts
2026-03-19 19:26:36 -04:00
Kit Langton
be7a9c4987 extract expandHome utility, fix scan error handling scope 2026-03-19 16:54:06 -04:00
Kit Langton
47cb07a8cf effectify Skill.load: replace Effect.promise blob with native Effect operations
Convert add/scan/load from async functions wrapped in Effect.promise to
proper Effect.fn generators using AppFileSystem.Service for isDir, glob,
and up operations. This eliminates the nested Effect.runPromise call for
discovery.pull and enables concurrent skill file processing.
2026-03-19 16:38:04 -04:00
Kit Langton
5f5546ee9b log errors in catchCause instead of silently swallowing 2026-03-19 16:21:57 -04:00
Kit Langton
d3972f7107 use forkScoped + Fiber.join for lazy Skill init (replace ensure pattern) 2026-03-19 16:14:48 -04:00
Kit Langton
b9de3ad370 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-19 15:12:21 -04:00
133 changed files with 3131 additions and 4843 deletions

4
.github/VOUCHED.td vendored
View File

@@ -10,7 +10,6 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-danieljoshuanazareth
edemaine
-florianleibert
fwang
@@ -18,9 +17,8 @@ iamdavidhill
jayair
kitlangton
kommander
-opencode2026
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-danieljoshuanazareth
-OpenCode2026

View File

@@ -325,6 +325,8 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -356,7 +358,6 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -367,7 +368,6 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
@@ -1110,6 +1110,10 @@
"@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=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -3028,8 +3032,6 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "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-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
"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=="],
@@ -3782,8 +3784,6 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -4246,7 +4246,7 @@
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
@@ -5060,6 +5060,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=="],
@@ -5456,10 +5460,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.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
"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=="],
@@ -5536,8 +5536,6 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6288,8 +6286,6 @@
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
}
}

View File

@@ -169,70 +169,6 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
}
}
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
@@ -282,56 +218,6 @@ test("review applies inline comment clicks without horizontal overflow", async (
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)

View File

@@ -1383,16 +1383,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) {
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
const file = e.currentTarget.files?.[0]
if (file) void addAttachment(file)
e.currentTarget.value = ""
}}
/>

View File

@@ -1,6 +1,4 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
@@ -20,6 +18,61 @@ const TEXT_MIMES = new Set([
"application/yaml",
])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096
function kind(type: string) {

View File

@@ -1,89 +0,0 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}

View File

@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }

View File

@@ -1,5 +1,4 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"

View File

@@ -217,6 +217,17 @@ export function FileTabContent(props: { tab: string }) {
onDelete={controls.remove}
/>
),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
})
createEffect(() => {
@@ -415,6 +426,7 @@ export function FileTabContent(props: { tab: string }) {
commentsUi.onLineSelectionEnd(range)
}}
search={search}
overflow="scroll"
class="select-text"
media={{
mode: "auto",

View File

@@ -24,13 +24,7 @@ import {
FreeUsageLimitError,
SubscriptionUsageLimitError,
} from "./error"
import {
buildCostChunk,
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
UsageInfo,
} from "./provider/provider"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
@@ -96,7 +90,7 @@ export async function handler(
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
logger.metric({
is_stream: isStream,
is_tream: isStream,
session: sessionId,
request: requestId,
client: ocClient,
@@ -236,7 +230,7 @@ export async function handler(
const body = JSON.stringify(
responseConverter({
...json,
cost: calculateOccurredCost(billingSource, costInfo),
cost: calculateOccuredCost(billingSource, costInfo),
}),
)
logger.metric({ response_length: body.length })
@@ -280,8 +274,8 @@ export async function handler(
await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
const cost = calculateOccuredCost(billingSource, costInfo)
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
}
c.close()
return
@@ -824,7 +818,7 @@ export async function handler(
}
}
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
}

View File

@@ -167,6 +167,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
}
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => ({

View File

@@ -56,6 +56,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
usage = json.usageMetadata
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {

View File

@@ -54,6 +54,7 @@ export const oaCompatHelper: ProviderHelper = () => ({
usage = json.usage
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {

View File

@@ -44,6 +44,7 @@ export const openaiHelper: ProviderHelper = () => ({
usage = json.response.usage
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {

View File

@@ -43,6 +43,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
buidlCostChunk: (cost: string) => string
}
normalizeUsage: (usage: any) => UsageInfo
}
@@ -161,19 +162,6 @@ export interface CommonChunk {
}
}
export function buildCostChunk(format: ZenData.Format, cost: string): string {
switch (format) {
case "anthropic":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "openai":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "oa-compat":
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
default:
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
}
}
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => {
if (from === to) return body

View File

@@ -6,11 +6,6 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
import { getStore } from "./store"
import { setTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
type Deps = {
killSidecar: () => void
installCli: () => Promise<string>
@@ -99,15 +94,11 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle(
"open-file-picker",
async (
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
) => {
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0]

View File

@@ -50,8 +50,6 @@ export type ElectronAPI = {
multiple?: boolean
title?: string
defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<string | string[] | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void

View File

@@ -1,8 +1,6 @@
// @refresh reload
import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders,
AppInterface,
handleNotificationClick,
@@ -113,8 +111,6 @@ const createPlatform = (): Platform => {
const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
})
return handleWslPicker(result)
},

View File

@@ -1,8 +1,6 @@
// @refresh reload
import {
ACCEPTED_FILE_EXTENSIONS,
filePickerFilters,
AppBaseProviders,
AppInterface,
handleNotificationClick,
@@ -100,7 +98,6 @@ const createPlatform = (): Platform => {
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
})
return handleWslPicker(result)
},

View File

@@ -90,6 +90,8 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -121,7 +123,6 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -132,7 +133,6 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",

View File

@@ -199,19 +199,6 @@ for (const item of targets) {
},
})
// Smoke test: only run if binary is for current platform
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
const binaryPath = `dist/${name}/bin/opencode`
console.log(`Running smoke test: ${binaryPath} --version`)
try {
const versionOutput = await $`${binaryPath} --version`.text()
console.log(`Smoke test passed: ${versionOutput.trim()}`)
} catch (e) {
console.error(`Smoke test failed for ${name}:`, e)
process.exit(1)
}
}
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(

View File

@@ -11,6 +11,7 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
@@ -54,6 +55,7 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
}

View File

@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use the shared runtime for process-wide services with one lifecycle for the whole app.
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
- Global services (no per-directory state): Account, Auth, Installation, Truncate
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
- 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 needs `InstanceState`.
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
For a fully migrated module, use the public namespace directly:
```ts
export namespace Foo {
@@ -28,52 +28,53 @@ export namespace Foo {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
return Service.of({
get: Effect.fn("Foo.get")(function* (id) {
return yield* ...
}),
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
}
```
Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
- 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
## Schema → Zod interop
## Temporary mixed-mode pattern
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
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
import { zod } from "@/util/effect-zod"
export namespace FooEffect {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
}
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(...)
}
```
See `Auth.ZodInfo` for the canonical example.
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
@@ -106,23 +107,22 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist
Fully migrated (single namespace, InstanceState where needed, flattened facade):
Done now:
- [x] `Account``account/index.ts`
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
- [x] `File``file/index.ts`
- [x] `FileTime``file/time.ts`
- [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts`
- [x] `Permission``permission/index.ts`
- [x] `ProviderAuth``provider/auth.ts`
- [x] `Question``question/index.ts`
- [x] `Skill``skill/index.ts`
- [x] `Snapshot``snapshot/index.ts`
- [x] `Truncate``tool/truncate.ts`
- [x] `Vcs``project/vcs.ts`
- [x] `Discovery``skill/discovery.ts`
- [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:
@@ -130,6 +130,7 @@ Still open and likely worth migrating:
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`
- [ ] `Config`

View File

@@ -0,0 +1,360 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace AccountEffect {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
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,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}

View File

@@ -1,397 +1,41 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Option } from "effect"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
Account as AccountSchema,
type AccountError,
AccessToken,
type AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
AccountEffect,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
} from "./effect"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export { AccessToken, AccountID, OrgID } from "./effect"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f))
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f))
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export const Account = AccountSchema
export type Account = AccountSchema
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const runPromise = makeRunPromise(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const cfg = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(cfg)
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
}
}

View File

@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
export namespace AccountRepo {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Account)
const query = <A>(f: (db: DbClient) => A) =>
Effect.try({
@@ -136,8 +136,6 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,

View File

@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
)
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,

View File

@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
@@ -32,7 +32,7 @@ export namespace Agent {
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset,
permission: PermissionNext.Ruleset,
model: z
.object({
modelID: ModelID.zod,
@@ -54,7 +54,7 @@ export namespace Agent {
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
@@ -72,16 +72,16 @@ export namespace Agent {
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
question: "allow",
plan_enter: "allow",
}),
@@ -94,9 +94,9 @@ export namespace Agent {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
@@ -116,9 +116,9 @@ export namespace Agent {
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
@@ -130,9 +130,9 @@ export namespace Agent {
},
explore: {
name: "explore",
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
@@ -161,9 +161,9 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
"*": "deny",
}),
user,
@@ -177,9 +177,9 @@ export namespace Agent {
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
"*": "deny",
}),
user,
@@ -192,9 +192,9 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: Permission.merge(
permission: PermissionNext.merge(
defaults,
Permission.fromConfig({
PermissionNext.fromConfig({
"*": "deny",
}),
user,
@@ -213,7 +213,7 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
permission: PermissionNext.merge(defaults, user),
options: {},
native: false,
}
@@ -229,7 +229,7 @@ export namespace Agent {
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -242,9 +242,9 @@ export namespace Agent {
})
if (explicit) continue
result[name].permission = Permission.merge(
result[name].permission = PermissionNext.merge(
result[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
@@ -322,11 +322,11 @@ export namespace Agent {
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false,
}),
onError: () => {},

View File

@@ -0,0 +1,94 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
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 })
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 class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
}

View File

@@ -1,101 +1,43 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRunPromise } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Effect } from "effect"
import z from "zod"
import { runtime } from "@/effect/runtime"
import * as S from "./effect"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export { OAUTH_DUMMY_KEY } from "./effect"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f))
}
export namespace Auth {
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export const Oauth = z
.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
})
.meta({ ref: "OAuth" })
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export const Api = z
.object({
type: z.literal("api"),
key: z.string(),
})
.meta({ ref: "ApiAuth" })
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const WellKnown = z
.object({
type: z.literal("wellknown"),
key: z.string(),
token: z.string(),
})
.meta({ ref: "WellKnownAuth" })
const _Info = Schema.Union([Oauth, Api, WellKnown])
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
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 class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
const runPromise = makeRunPromise(Service, layer)
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View File

@@ -1,5 +1,5 @@
import z from "zod"
import type { ZodType } from "zod"
import type { ZodObject, ZodRawShape } from "zod"
import { Log } from "../util/log"
export namespace BusEvent {
@@ -9,7 +9,7 @@ export namespace BusEvent {
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
const result = {
type,
properties,

View File

@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
payload: any
payload: { type: string; properties: Record<string, unknown> }
},
]
}>()

View File

@@ -1,7 +1,8 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -16,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* Account.Service
const service = yield* AccountEffect.Service
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
@@ -57,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* Account.Service
const service = yield* AccountEffect.Service
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
@@ -97,7 +98,7 @@ interface OrgChoice {
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* Account.Service
const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
@@ -128,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* Account.Service
const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
@@ -159,7 +160,7 @@ export const LoginCommand = cmd({
}),
async handler(args) {
UI.empty()
await Account.runPromise((_svc) => loginEffect(args.url))
await runtime.runPromise(loginEffect(args.url))
},
})
@@ -173,7 +174,7 @@ export const LogoutCommand = cmd({
}),
async handler(args) {
UI.empty()
await Account.runPromise((_svc) => logoutEffect(args.email))
await runtime.runPromise(logoutEffect(args.email))
},
})
@@ -182,7 +183,7 @@ export const SwitchCommand = cmd({
describe: false,
async handler() {
UI.empty()
await Account.runPromise((_svc) => switchEffect())
await runtime.runPromise(switchEffect())
},
})
@@ -191,7 +192,7 @@ export const OrgsCommand = cmd({
describe: false,
async handler() {
UI.empty()
await Account.runPromise((_svc) => orgsEffect())
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission"
import { PermissionNext } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = Permission.disabled(
const disabled = PermissionNext.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
)
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
}
await Session.updateMessage(message)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
return {
sessionID: session.id,
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal,
messages: [],
metadata: () => {},
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
throw new PermissionNext.DeniedError({ ruleset })
}
}
},

View File

@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { PermissionNext } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
process.exit(1)
}
const rules: Permission.Ruleset = [
const rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",

View File

@@ -1667,7 +1667,6 @@ function InlineTool(props: {
const denied = createMemo(
() =>
error()?.includes("QuestionRejectedError") ||
error()?.includes("rejected permission") ||
error()?.includes("specified a rule") ||
error()?.includes("user dismissed"),

View File

@@ -58,10 +58,10 @@ export const UpgradeCommand = {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again")
} else {
prompts.log.error(err.stderr)
prompts.log.error(err.data.stderr)
}
} else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")

View File

@@ -177,7 +177,7 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = await Account.active()
const active = Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([

View File

@@ -1,5 +1,4 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
@@ -109,9 +108,7 @@ export namespace ConfigPaths {
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
filePath = Filesystem.expandHome(filePath)
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (

View File

@@ -124,7 +124,7 @@ export namespace Workspace {
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", {
directory: space.id,
payload: event,
payload: event as { type: string; properties: Record<string, unknown> },
})
})
// Wait 250ms and retry if SSE connection fails

View File

@@ -1,47 +0,0 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance, type Shape } from "@/project/instance"
import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState"
export interface InstanceState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export namespace InstanceState {
export const make = <A, E = never, R = never>(
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () => init(Instance.current),
})
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
yield* Effect.addFinalizer(() => Effect.sync(off))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(
self: InstanceState<A, E, R>,
select: (value: A) => Effect.Effect<B, E2, R2>,
) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
}

View File

@@ -0,0 +1,68 @@
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 { 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 { 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
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(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),
).pipe(Layer.provide(ctx))
}
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
"opencode/Instances",
) {
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
}),
)
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
}

View File

@@ -1,13 +0,0 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
rt ??= ManagedRuntime.make(layer, { memoMap })
return rt.runPromise(service.use(fn), options)
}
}

View File

@@ -0,0 +1,23 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect"
import { AuthEffect } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}

View File

@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
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"
@@ -83,6 +83,26 @@ export namespace File {
),
}
export function init() {
return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input)))
}
const log = Log.create({ service: "file" })
const binary = new Set([
@@ -179,6 +199,12 @@ export namespace File {
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
@@ -297,7 +323,7 @@ export namespace File {
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.debug("shouldEncode", { type })
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
@@ -321,11 +347,6 @@ export namespace File {
return [...visible, ...hiddenItems]
}
interface State {
cache: Entry
fiber: Fiber.Fiber<void> | undefined
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
@@ -344,18 +365,12 @@ export namespace File {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
Effect.succeed({
cache: { files: [], dirs: [] } as Entry,
fiber: undefined as Fiber.Fiber<void> | undefined,
}),
),
)
const instance = yield* InstanceContext
let cache: Entry = { files: [], dirs: [] }
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 isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
@@ -366,7 +381,7 @@ export namespace File {
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 })
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
@@ -374,7 +389,7 @@ export namespace File {
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(Instance.directory, 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
@@ -386,7 +401,7 @@ export namespace File {
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
@@ -402,38 +417,31 @@ export namespace File {
}
})
const s = yield* InstanceState.get(state)
s.cache = next
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
const ensure = Effect.fn("File.ensure")(function* () {
const s = yield* InstanceState.get(state)
if (!s.fiber)
s.fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
s.fiber = undefined
}),
),
Effect.forkIn(scope),
)
yield* Fiber.join(s.fiber)
})
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
yield* ensure()
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
cwd: instance.directory,
})
).text()
@@ -463,7 +471,7 @@ export namespace File {
"--exclude-standard",
],
{
cwd: Instance.directory,
cwd: instance.directory,
},
)
).text()
@@ -471,7 +479,7 @@ export namespace File {
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(Instance.directory, file))
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
@@ -497,7 +505,7 @@ export namespace File {
"HEAD",
],
{
cwd: Instance.directory,
cwd: instance.directory,
},
)
).text()
@@ -514,10 +522,10 @@ export namespace File {
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
path: path.relative(instance.directory, full),
}
})
})
@@ -526,7 +534,7 @@ export namespace File {
const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
@@ -574,19 +582,19 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (Instance.project.vcs === "git") {
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -608,20 +616,20 @@ export namespace File {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
@@ -630,7 +638,7 @@ export namespace File {
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 file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
@@ -654,16 +662,13 @@ export namespace File {
dirs?: boolean
type?: "file" | "directory"
}) {
yield* ensure()
const { cache } = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = cache
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
@@ -687,26 +692,4 @@ export namespace File {
return Service.of({ init, status, read, list, search })
}),
)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
}

View File

@@ -1,6 +1,5 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
@@ -36,11 +35,6 @@ export namespace FileTime {
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
@@ -54,40 +48,30 @@ export namespace FileTime {
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
const locks = (yield* InstanceState.get(state)).locks
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const reads = (yield* InstanceState.get(state)).reads
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`)
@@ -101,28 +85,26 @@ export namespace FileTime {
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
const runPromise = makeRunPromise(Service, layer)
)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
}
export function get(sessionID: SessionID, file: string) {
return runPromise((s) => s.get(sessionID, file))
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromise((s) => s.assert(sessionID, filepath))
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn))
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
}
}

View File

@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
import { Cause, Effect, Layer, ServiceMap } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
@@ -7,8 +7,7 @@ import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { git } from "@/util/git"
@@ -61,107 +60,82 @@ export namespace FileWatcher {
export const hasNativeBinding = () => !!watcher()
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
log.info("init", { directory: Instance.directory })
log.info("init", { directory: instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return Service.of({})
}
const w = watcher()
if (!w) return
const w = watcher()
if (!w) return Service.of({})
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* Effect.promise(() => Config.get())
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
),
)
)
}
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}),
const cfg = yield* Effect.promise(() => Config.get())
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
}
if (instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
cwd: instance.project.worktree,
}),
)
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
}),
),
)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
}

View File

@@ -69,7 +69,6 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")

View File

@@ -1,6 +1,6 @@
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
@@ -27,7 +27,6 @@ export namespace Format {
export type Status = z.infer<typeof Status>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
}
@@ -36,124 +35,106 @@ export namespace Format {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const instance = yield* InstanceContext
const cfg = yield* Effect.promise(() => Config.get())
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
const cfg = yield* Effect.promise(() => Config.get())
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function getFormatter(ext: string) {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const checks = await Promise.all(
matching.map(async (item) => {
log.info("checking", { name: item.name, ext })
const on = await isEnabled(item)
if (on) {
log.info("enabled", { name: item.name, ext })
}
return {
item,
enabled: on,
}
}),
)
return checks.filter((x) => x.enabled).map((x) => x.item)
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
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)
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
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.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)
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,
})
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
return {
formatters,
isEnabled,
}
}),
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
const init = Effect.fn("Format.init")(function* () {
yield* InstanceState.get(state)
})
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const { formatters, isEnabled } = yield* InstanceState.get(state)
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
@@ -166,17 +147,11 @@ export namespace Format {
return result
})
return Service.of({ init, status })
return Service.of({ status })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() {
return runPromise((s) => s.status())
return runPromiseInstance(Service.use((s) => s.status()))
}
}

View File

@@ -1,14 +1,12 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { BusEvent } from "@/bus/bus-event"
import path from "path"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global {
const OPENCODE_VERSION: string
@@ -18,7 +16,39 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
Updated: BusEvent.define(
@@ -45,9 +75,12 @@ export namespace Installation {
})
export type Info = z.infer<typeof Info>
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export async function info() {
return {
version: VERSION,
latest: await latest(),
}
}
export function isPreview() {
return CHANNEL !== "latest"
@@ -57,294 +90,214 @@ export namespace Installation {
return CHANNEL === "local"
}
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
stderr: Schema.String,
}) {}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
// Response schemas for external version APIs
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
const NpmPackage = Schema.Struct({ version: Schema.String })
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
const BrewInfoV2 = Schema.Struct({
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
})
const ChocoPackage = Schema.Struct({
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
})
const ScoopManifest = NpmPackage
const checks = [
{
name: "npm" as const,
command: () => text(["npm", "list", "-g", "--depth=0"]),
},
{
name: "yarn" as const,
command: () => text(["yarn", "global", "list"]),
},
{
name: "pnpm" as const,
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly method: () => Effect.Effect<Method>
readonly latest: (method?: Method) => Effect.Effect<string>
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown"
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const text = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
yield* handle.exitCode
return out
},
Effect.scoped,
Effect.catch(() => Effect.succeed("")),
)
const run = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, 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, stdout, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, 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, stdout, stderr }
},
Effect.scoped,
Effect.orDie,
)
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown" as Method
})
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* methodImpl())
if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = CHANNEL
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: VERSION,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function info(): Promise<Info> {
return runPromise((svc) => svc.info())
async function getBrewFormula() {
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function method(): Promise<Method> {
return runPromise((svc) => svc.method())
export async function upgrade(method: Method, target: string) {
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
result = await upgradeCurl(target)
break
case "npm":
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await Process.text([process.execPath, "--version"], { nothrow: true })
}
export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
export async function latest(installMethod?: Method) {
const detectedMethod = installMethod || (await method())
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
}
}

View File

@@ -1,13 +1,13 @@
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
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"
@@ -16,7 +16,7 @@ import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace Permission {
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
@@ -125,46 +125,24 @@ export namespace Permission {
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Ruleset
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
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 { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@@ -201,7 +179,6 @@ export namespace Permission {
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return
@@ -259,7 +236,6 @@ export namespace Permission {
})
const list = Effect.fn("Permission.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (item) => item.info)
})
@@ -293,6 +269,14 @@ export namespace Permission {
return rulesets.flat()
}
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
export async function list() {
return runPromiseInstance(Service.use((svc) => svc.list()))
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
@@ -305,18 +289,4 @@ export namespace Permission {
}
return result
}
export const runPromise = makeRunPromise(Service, layer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))
}
export async function reply(input: z.infer<typeof ReplyInput>) {
return runPromise((s) => s.reply(input))
}
export async function list() {
return runPromise((s) => s.list())
}
}

View File

@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
const log = Log.create({ service: "plugin" })

View File

@@ -1,11 +1,7 @@
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot"
import { Project } from "./project"
import { Vcs } from "./vcs"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
@@ -16,12 +12,8 @@ export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
Format.init()
await LSP.init()
File.init()
FileWatcher.init()
Vcs.init()
Snapshot.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View File

@@ -7,13 +7,13 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
export interface Shape {
interface Context {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Shape>("instance")
const cache = new Map<string, Promise<Shape>>()
const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>()
const disposal = {
all: undefined as Promise<void> | undefined,
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}
function track(directory: string, next: Promise<Shape>) {
function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error

View File

@@ -1,8 +1,7 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git"
@@ -31,81 +30,54 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
}
interface State {
current: string | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
return { current: undefined }
}
const instance = yield* InstanceContext
let currentBranch: string | undefined
const getCurrentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: ctx.worktree,
})
if (result.exitCode !== 0) return undefined
const text = result.text().trim()
return text || 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 value = {
current: yield* Effect.promise(() => getCurrentBranch()),
}
log.info("initialized", { branch: value.current })
currentBranch = yield* Effect.promise(() => getCurrentBranch())
log.info("initialized", { branch: currentBranch })
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 !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
return value
}),
),
)
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),
)
}
return Service.of({
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state)
}),
branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current)
return currentBranch
}),
})
}),
)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
export function branch() {
return runPromise((svc) => svc.branch())
}
}

View File

@@ -1,11 +1,10 @@
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Plugin } from "../plugin"
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 } from "effect"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
import z from "zod"
export namespace ProviderAuth {
@@ -92,8 +91,6 @@ export namespace ProviderAuth {
| InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed>
type Hook = NonNullable<Hooks["auth"]>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: {
@@ -104,37 +101,26 @@ export namespace ProviderAuth {
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
}
interface State {
hooks: Record<ProviderID, Hook>
pending: Map<ProviderID, AuthOuathResult>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const state = yield* InstanceState.make<State>(
Effect.fn("ProviderAuth.state")(() =>
Effect.promise(async () => {
const plugins = await Plugin.list()
return {
hooks: Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
),
pending: new Map<ProviderID, AuthOuathResult>(),
}
}),
),
)
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* () {
const hooks = (yield* InstanceState.get(state)).hooks
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
@@ -168,7 +154,6 @@ export namespace ProviderAuth {
method: number
inputs?: Record<string, string>
}) {
const { hooks, pending } = yield* InstanceState.get(state)
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
@@ -195,7 +180,6 @@ export namespace ProviderAuth {
method: number
code?: string
}) {
const pending = (yield* InstanceState.get(state)).pending
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) {
@@ -229,23 +213,27 @@ export namespace ProviderAuth {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
export async function methods() {
return runPromise((svc) => svc.methods())
return runPromiseInstance(Service.use((svc) => svc.methods()))
}
export async function authorize(input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}): Promise<Authorization | undefined> {
return runPromise((svc) => svc.authorize(input))
}
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))),
)
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
return runPromise((svc) => svc.callback(input))
}
export const callback = fn(
z.object({
providerID: ProviderID.zod,
method: z.number(),
code: z.string().optional(),
}),
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
)
}

View File

@@ -40,12 +40,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import {
createGitLab,
VERSION as GITLAB_PROVIDER_VERSION,
isWorkflowModel,
discoverWorkflowModels,
} from "gitlab-ai-provider"
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform"
@@ -129,20 +124,18 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
"gitlab-ai-provider": createGitLab,
"@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
type CustomDiscoverModels = () => Promise<Record<string, Model>>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: CustomModelLoader
vars?: CustomVarsLoader
options?: Record<string, any>
discoverModels?: CustomDiscoverModels
}>
function useLanguageModel(sdk: any) {
@@ -540,105 +533,28 @@ export namespace Provider {
...(providerConfig?.options?.aiGatewayHeaders || {}),
}
const featureFlags = {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
}
return {
autoload: !!apiKey,
options: {
instanceUrl,
apiKey,
aiGatewayHeaders,
featureFlags,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
if (modelID.startsWith("duo-workflow-")) {
const workflowRef = options?.workflowRef as string | undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const model = sdk.workflowChat(sdkModelID, {
featureFlags,
})
if (workflowRef) {
model.selectedModelRef = workflowRef
}
return model
}
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
aiGatewayHeaders,
featureFlags,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
})
},
async discoverModels(): Promise<Record<string, Model>> {
if (!apiKey) {
log.info("gitlab model discovery skipped: no apiKey")
return {}
}
try {
const token = apiKey
const getHeaders = (): Record<string, string> =>
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
const result = await discoverWorkflowModels(
{ instanceUrl, getHeaders },
{ workingDirectory: Instance.directory },
)
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
})
return {}
}
const models: Record<string, Model> = {}
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ModelID.make(m.id),
providerID: ProviderID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
api: {
id: m.id,
url: instanceUrl,
npm: "gitlab-ai-provider",
},
status: "active",
headers: {},
options: { workflowRef: m.ref },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: m.context, output: m.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: {},
}
}
}
log.info("gitlab model discovery complete", {
count: Object.keys(models).length,
models: Object.keys(models),
})
return models
} catch (e) {
log.warn("gitlab model discovery failed", { error: e })
return {}
}
},
}
},
"cloudflare-workers-ai": async (input) => {
@@ -937,9 +853,6 @@ export namespace Provider {
const varsLoaders: {
[providerID: string]: CustomVarsLoader
} = {}
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1096,7 +1009,6 @@ export namespace Provider {
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
if (result.vars) varsLoaders[providerID] = result.vars
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
const opts = result.options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
mergeProvider(providerID, patch)
@@ -1158,18 +1070,6 @@ export namespace Provider {
log.info("found", { providerID })
}
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab]) {
await (async () => {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
}
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
}
return {
models: languages,
providers,
@@ -1350,7 +1250,7 @@ export namespace Provider {
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options })
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language

View File

@@ -1,16 +1,15 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question {
const log = Log.create({ service: "question" })
const log = Log.create({ service: "question" })
export namespace Question {
// Schemas
export const Option = z
@@ -87,10 +86,6 @@ export namespace Question {
deferred: Deferred.Deferred<Answer[], RejectedError>
}
interface State {
pending: Map<QuestionID, PendingEntry>
}
// Service
export interface Interface {
@@ -109,31 +104,13 @@ export namespace Question {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
)
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
@@ -156,7 +133,6 @@ export namespace Question {
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
@@ -173,7 +149,6 @@ export namespace Question {
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
@@ -189,7 +164,6 @@ export namespace Question {
})
const list = Effect.fn("Question.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (x) => x.info)
})
@@ -197,25 +171,23 @@ export namespace Question {
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromise((s) => s.ask(input))
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
return runPromise((s) => s.reply(input))
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
}
export async function reject(requestID: QuestionID) {
return runPromise((s) => s.reject(requestID))
export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
}
export async function list() {
return runPromise((s) => s.list())
export async function list(): Promise<Request[]> {
return runPromiseInstance(Service.use((svc) => svc.list()))
}
}

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod,
}),
),
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Permission.reply({
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(Permission.Request.array()),
schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = await Permission.list()
const permissions = await PermissionNext.list()
return c.json(permissions)
},
),

View File

@@ -9,9 +9,6 @@ import { ProviderID } from "../../provider/schema"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "../../util/log"
const log = Log.create({ service: "server" })
export const ProviderRoutes = lazy(() =>
new Hono()

View File

@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod,
}),
),
validator("json", z.object({ response: Permission.Reply })),
validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
Permission.reply({
PermissionNext.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})

View File

@@ -12,8 +12,9 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Skill } from "../skill/skill"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
@@ -151,7 +152,7 @@ export namespace Server {
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info.zod),
validator("json", Auth.Info),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
@@ -330,7 +331,7 @@ export namespace Server {
},
}),
async (c) => {
const branch = await Vcs.branch()
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({
branch,
})

View File

@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
@@ -148,7 +148,7 @@ export namespace Session {
compacting: z.number().optional(),
archived: z.number().optional(),
}),
permission: Permission.Ruleset.optional(),
permission: PermissionNext.Ruleset.optional(),
revert: z
.object({
messageID: MessageID.zod,
@@ -300,7 +300,7 @@ export namespace Session {
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
permission?: Permission.Ruleset
permission?: PermissionNext.Ruleset
}) {
const result: Info = {
id: SessionID.descending(input.id),
@@ -423,7 +423,7 @@ export namespace Session {
export const setPermission = fn(
z.object({
sessionID: SessionID.zod,
permission: Permission.Ruleset,
permission: PermissionNext.Ruleset,
}),
async (input) => {
return Database.use((db) => {

View File

@@ -95,9 +95,7 @@ export namespace InstructionPrompt {
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
}
instruction = Filesystem.expandHome(instruction)
const matches = path.isAbsolute(instruction)
? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),

View File

@@ -12,7 +12,6 @@ import {
jsonSchema,
} from "ai"
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
@@ -21,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { Auth } from "@/auth"
export namespace LLM {
@@ -33,7 +32,7 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
permission?: PermissionNext.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
@@ -64,14 +63,14 @@ export namespace LLM {
Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID),
])
// TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const system: string[] = []
const system = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
@@ -109,22 +108,10 @@ export namespace LLM {
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
if (isCodex) {
options.instructions = SystemPrompt.instructions()
}
const messages = isOpenaiOauth
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = await Plugin.trigger(
"chat.params",
{
@@ -159,9 +146,7 @@ export namespace LLM {
)
const maxOutputTokens =
isOpenaiOauth || provider.id.includes("github-copilot")
? undefined
: ProviderTransform.maxOutputTokens(input.model)
isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model)
const tools = await resolveTools(input)
@@ -185,34 +170,6 @@ export namespace LLM {
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
}
return streamText({
onError(error) {
l.error("stream error", {
@@ -260,7 +217,15 @@ export namespace LLM {
...headers,
},
maxRetries: input.retries ?? 0,
messages,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
],
model: wrapLanguageModel({
model: language,
middleware: [
@@ -286,9 +251,9 @@ export namespace LLM {
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
const disabled = PermissionNext.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
PermissionNext.merge(input.agent.permission, input.permission ?? []),
)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {

View File

@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
)
) {
const agent = await Agent.get(input.assistantMessage.agent)
await Permission.ask({
await PermissionNext.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID,
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
})
if (
value.error instanceof Permission.RejectedError ||
value.error instanceof PermissionNext.RejectedError ||
value.error instanceof Question.RejectedError
) {
blocked = shouldBreak

View File

@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
// this is backwards compatibility for allowing `tools` to be specified when
// prompting
const permissions: Permission.Ruleset = []
const permissions: PermissionNext.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({
permission: tool,
@@ -203,7 +203,7 @@ export namespace SessionPrompt {
if (seen.has(name)) return
seen.add(name)
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
? Filesystem.expandHome(name)
: path.resolve(Instance.worktree, name)
const stats = await fs.stat(filepath).catch(() => undefined)
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
},
async ask(req) {
await Permission.ask({
await PermissionNext.ask({
...req,
sessionID: sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
})
},
}
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
}
},
async ask(req) {
await Permission.ask({
await PermissionNext.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
})
},
})
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
if (part.type === "agent") {
// Check if this agent would be denied by task permission
const perm = Permission.evaluate("task", part.name, agent.permission)
const perm = PermissionNext.evaluate("task", part.name, agent.permission)
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [
{

View File

@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot"
import type { Permission } from "../permission"
import type { PermissionNext } from "../permission"
import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema"
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
})

View File

@@ -7,18 +7,22 @@ import PROMPT_DEFAULT from "./prompt/default.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
import { Skill } from "@/skill"
export namespace SystemPrompt {
export function instructions() {
return PROMPT_CODEX.trim()
}
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) return [PROMPT_CODEX]
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]
@@ -53,7 +57,7 @@ export namespace SystemPrompt {
}
export async function skills(agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent)

View File

@@ -45,7 +45,7 @@ export namespace ShareNext {
}> {
const headers: Record<string, string> = {}
const active = await Account.active()
const active = Account.active()
if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl }

View File

@@ -1,262 +1 @@
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 { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
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(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
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 = (discovery: Discovery.Interface, directory: string, worktree: string): 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: directory,
stop: 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(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, Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const state = yield* InstanceState.make(
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
)
const ensure = Effect.fn("Skill.ensure")(function* () {
const cache = yield* InstanceState.get(state)
yield* Effect.promise(() => cache.ensure())
return cache
})
const get = Effect.fn("Skill.get")(function* (name: string) {
const cache = yield* ensure()
return cache.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
const cache = yield* ensure()
return Object.values(cache.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
const cache = yield* ensure()
return Array.from(cache.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
const cache = yield* ensure()
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(name: string) {
return runPromise((skill) => skill.get(name))
}
export async function all() {
return runPromise((skill) => skill.all())
}
export async function dirs() {
return runPromise((skill) => skill.dirs())
}
export async function available(agent?: Agent.Info) {
return runPromise((skill) => skill.available(agent))
}
}
export * from "./skill"

View File

@@ -0,0 +1,268 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Fiber, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { AppFileSystem } from "@/filesystem"
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 { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
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(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
type State = {
skills: Record<string, Info>
dirs: Set<string>
}
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[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service | AppFileSystem.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const fs = yield* AppFileSystem.Service
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const add = Effect.fn("Skill.add")(function* (match: string) {
const md = yield* Effect.tryPromise(() => ConfigMarkdown.parse(match)).pipe(
Effect.catch((err) =>
Effect.gen(function* () {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = yield* Effect.promise(() => 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 = Effect.fn("Skill.scan")(function* (
root: string,
pattern: string,
opts?: { dot?: boolean; scope?: string },
) {
const matches = yield* fs
.glob(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.pipe(
Effect.catch((error) => {
if (!opts?.scope) return Effect.fail(error)
return Effect.sync(() => {
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
return [] as string[]
})
}),
)
yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" })
})
const load = Effect.fn("Skill.load")(function* () {
// Phase 1: External dirs (global)
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue
yield* scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
// Phase 2: External dirs (project, walk up)
const roots = yield* fs
.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})
.pipe(Effect.orDie)
yield* Effect.forEach(
roots,
(root) => scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }),
{ concurrency: "unbounded" },
)
}
// Phase 3: Config directories
const dirs = yield* Effect.promise(() => Config.directories())
yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" })
// Phase 4: Custom paths
const cfg = yield* Effect.promise(() => Config.get())
for (const item of cfg.skills?.paths ?? []) {
const expanded = Filesystem.expandHome(item)
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) {
log.warn("skill path not found", { path: dir })
continue
}
yield* scan(dir, SKILL_PATTERN)
}
// Phase 5: Remote URLs
for (const url of cfg.skills?.urls ?? []) {
const pullDirs = yield* discovery.pull(url)
for (const dir of pullDirs) {
state.dirs.add(dir)
yield* scan(dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
})
const loadFiber = yield* load().pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
Effect.forkScoped,
)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Fiber.join(loadFiber)
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Fiber.join(loadFiber)
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Fiber.join(loadFiber)
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Fiber.join(loadFiber)
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
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),
Layer.provide(AppFileSystem.defaultLayer),
)
export async function get(name: string) {
return runPromiseInstance(Service.use((skill) => skill.get(name)))
}
export async function all() {
return runPromiseInstance(Service.use((skill) => skill.all()))
}
export async function dirs() {
return runPromiseInstance(Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
}

View File

@@ -3,8 +3,8 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "ef
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
@@ -31,6 +31,34 @@ export namespace Snapshot {
})
export type FileDiff = z.infer<typeof FileDiff>
export async function cleanup() {
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
}
export async function track() {
return runPromiseInstance(Service.use((svc) => svc.track()))
}
export async function patch(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
}
export async function restore(snapshot: string) {
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
}
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
@@ -43,10 +71,7 @@ export namespace Snapshot {
readonly stderr: string
}
type State = Omit<Interface, "init">
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
@@ -58,300 +83,262 @@ export namespace Snapshot {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make<State>(
Effect.fn("Snapshot.state")(function* (ctx) {
const state = {
directory: ctx.directory,
worktree: ctx.worktree,
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
vcs: ctx.project.vcs,
}
export const layer: Layer.Layer<
Service,
never,
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* AppFileSystem.Service
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 args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
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),
}),
),
)
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))
const enabled = Effect.fnUntraced(function* () {
if (state.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: state.worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(state.gitdir, "info", "exclude")
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
})
const cleanup = Effect.fnUntraced(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(state.gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
const track = Effect.fnUntraced(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash
})
const patch = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
{
cwd: state.directory,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fnUntraced(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fnUntraced(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: state.worktree,
})
if (result.code !== 0) {
const rel = path.relative(state.worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
cwd: state.worktree,
})
if (tree.code === 0 && tree.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)
}
}
}
}
})
const diff = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fnUntraced(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, "--", "."])],
{ cwd: state.directory },
)
for (const line of statuses.text.trim().split("\n")) {
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 numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: state.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
? ["", ""]
: 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)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
}
return result
})
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 { cleanup, track, patch, restore, revert, diff, diffFull }
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
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),
}),
),
)
// 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))
const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
const excludes = Effect.fnUntraced(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
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)
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["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 })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, 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"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: gitdir })
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,
})
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fn("Snapshot.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 })
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()) {
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)
}
}
}
}
})
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("Snapshot.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, "--", "."])],
{ cwd: directory },
)
return Service.of({
init: Effect.fn("Snapshot.init")(function* () {
yield* InstanceState.get(state)
}),
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
}),
track: Effect.fn("Snapshot.track")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.track())
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
}),
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
}),
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
}),
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
}),
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
}),
})
}),
)
for (const line of statuses.text.trim().split("\n")) {
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 numstat = yield* git(
[...quote, ...args(["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
? ["", ""]
: 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)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
}
return result
})
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 })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
@@ -359,38 +346,4 @@ export namespace Snapshot {
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function cleanup() {
return runPromise((svc) => svc.cleanup())
}
export async function track() {
return runPromise((svc) => svc.track())
}
export async function patch(hash: string) {
return runPromise((svc) => svc.patch(hash))
}
export async function restore(snapshot: string) {
return runPromise((svc) => svc.restore(snapshot))
}
export async function revert(patches: Patch[]) {
return runPromise((svc) => svc.revert(patches))
}
export async function diff(hash: string) {
return runPromise((svc) => svc.diff(hash))
}
export async function diffFull(from: string, to: string) {
return runPromise((svc) => svc.diffFull(from, to))
}
}

View File

@@ -28,10 +28,6 @@ const log = Log.create({ service: "db" })
export namespace Database {
export const Path = iife(() => {
if (Flag.OPENCODE_DB) {
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
const channel = Installation.CHANNEL
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")

View File

@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { PermissionNext } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))

View File

@@ -1,7 +1,7 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { PermissionNext } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
@@ -23,7 +23,7 @@ export namespace Tool {
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string

View File

@@ -0,0 +1,137 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.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_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.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"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup 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, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
}

View File

@@ -1,144 +1,18 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect"
export namespace Truncate {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = S.MAX_LINES
export const MAX_BYTES = S.MAX_BYTES
export const DIR = S.DIR
export const GLOB = S.GLOB
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = S.Result
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.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_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.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"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup 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, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export type Options = S.Options
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runPromise((s) => s.output(text, options, agent))
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
}
}

View File

@@ -2,6 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import os from "os"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
@@ -95,6 +96,10 @@ export namespace Filesystem {
}
}
export function expandHome(p: string): string {
return p.startsWith("~/") ? join(os.homedir(), p.slice(2)) : p
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}

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 { Account } from "../../src/account"
import { AccountEffect } from "../../src/account/effect"
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) =>
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
@@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
)
const poll = (body: unknown, status = 400) =>
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
@@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
}),
)
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
const rows = yield* AccountEffect.Service.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")]],
@@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
),
)
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -178,7 +178,9 @@ it.effect("config sends the selected org header", () =>
}),
)
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
const cfg = yield* AccountEffect.Service.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({
@@ -207,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {

View File

@@ -1,20 +1,16 @@
import { afterEach, test, expect } from "bun:test"
import { test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { PermissionNext } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
if (!agent) return undefined
return Permission.evaluate(permission, "*", agent.permission).action
return PermissionNext.evaluate(permission, "*", agent.permission).action
}
afterEach(async () => {
await Instance.disposeAll()
})
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -58,7 +54,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
},
})
})
@@ -87,8 +83,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
},
})
})
@@ -220,7 +216,7 @@ test("agent permission config merges with defaults", async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
// Specific pattern is denied
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
@@ -505,9 +501,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -529,9 +525,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -552,8 +548,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
},
})
})
@@ -586,7 +582,7 @@ description: Permission skill.
const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
},
})
} finally {

View File

@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
Account.active = mock(async () => ({
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",

View File

@@ -1,384 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
import { InstanceState } from "../../src/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
afterEach(async () => {
await Instance.disposeAll()
})
test("InstanceState caches values per directory", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState isolates directories", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const a = yield* Effect.promise(() => access(state, one.path))
const b = yield* Effect.promise(() => access(state, two.path))
const c = yield* Effect.promise(() => access(state, one.path))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState invalidates on reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState invalidates on disposeAll", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* Effect.promise(() => access(state, one.path))
yield* Effect.promise(() => access(state, two.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort())
}),
),
)
})
test("InstanceState.get reads the current directory lazily", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
expect(b).toBe(two.path)
} finally {
await rt.dispose()
}
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const [a, b, c] = await Promise.all([
Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
} finally {
await rt.dispose()
}
})
test("InstanceState survives high-contention concurrent access", async () => {
const N = 20
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const results = await Promise.all(
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
)
for (let i = 0; i < N; i++) {
expect(results[i]).toBe(dirs[i].path)
}
} finally {
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5) // slow init
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
// Init both directories
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
// Dispose one directory, access the other concurrently
const [, b] = await Promise.all([
Instance.reload({ directory: one.path }),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42
// Access directory two — should be independent
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0)
// Confirm directory one still has the mutation
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference
}),
),
)
})
test("InstanceState dedupes concurrent lookups", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})

View File

@@ -1,46 +0,0 @@
import { expect, test } from "bun:test"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRunPromise } from "../../src/effect/run-service"
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const runOne = makeRunPromise(One, one)
const runTwo = makeRunPromise(Two, two)
expect(await runOne((svc) => svc.get())).toBe(1)
expect(await runTwo((svc) => svc.get())).toBe(1)
expect(n).toBe(1)
})

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, test, expect } from "bun:test"
import { describe, test, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
@@ -7,10 +7,6 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => {
@@ -693,18 +689,6 @@ describe("file/index Filesystem patterns", () => {
})
})
test("search works before explicit init", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
})
test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo()
@@ -801,23 +785,6 @@ describe("file/index Filesystem patterns", () => {
},
})
})
test("search refreshes after init when files change", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
const result = await File.search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts")
},
})
})
})
describe("File.read() - diff/patch", () => {
@@ -882,65 +849,4 @@ describe("file/index Filesystem patterns", () => {
})
})
})
describe("InstanceState isolation", () => {
test("two directories get independent file caches", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts")
const results2 = await File.search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts")
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts")
const results2 = await File.search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts")
},
})
})
test("disposal gives fresh state on next access", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "before", type: "file" })
expect(results).toContain("before.ts")
},
})
await Instance.disposeAll()
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
await fs.rm(path.join(tmp.path, "before.ts"))
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "after", type: "file" })
expect(results).toContain("after.ts")
const stale = await File.search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts")
},
})
})
})
})

View File

@@ -7,9 +7,7 @@ import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
afterEach(() => Instance.disposeAll())
async function touch(file: string, time: number) {
const date = new Date(time)
@@ -86,28 +84,6 @@ describe("file/time", () => {
},
})
})
test("isolates reads by directory", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await using shared = await tmpdir()
const filepath = path.join(shared.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
})
describe("assert()", () => {

View File

@@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers
// ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
@@ -25,7 +24,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
directory,
FileWatcher.layer,
async (rt) => {
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false
function on(evt: BusUpdate) {
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (done) return
if (evt.directory !== directory) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
if (!check(evt.payload.properties)) return
hit(evt.payload.properties)
}
if (!check(evt.properties)) return
hit(evt.properties)
})
function cleanup() {
return () => {
if (done) return
done = true
GlobalBus.off("event", on)
unsub()
}
GlobalBus.on("event", on)
return cleanup
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
@@ -136,9 +130,7 @@ function ready(directory: string) {
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
afterEach(async () => {
await Instance.disposeAll()
})
afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true })

View File

@@ -34,7 +34,7 @@ export function withServices<S>(
project: Instance.project,
}),
)
let resolved: Layer.Layer<S> = layer.pipe(Layer.provide(ctx)) as any
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
if (options?.provide) {
for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any

View File

@@ -2,16 +2,11 @@ import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { File } from "../../src/file"
import { Format } from "../../src/format"
import * as Formatter from "../../src/format/formatter"
import { Instance } from "../../src/project/instance"
describe("Format", () => {
afterEach(async () => {
await Instance.disposeAll()
})
afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
@@ -67,106 +62,4 @@ describe("Format", () => {
await rt.runPromise(Format.Service.use(() => Effect.void))
})
})
test("status() initializes formatter state per directory", async () => {
await using off = await tmpdir({
config: { formatter: false },
})
await using on = await tmpdir()
const a = await Instance.provide({
directory: off.path,
fn: () => Format.status(),
})
const b = await Instance.provide({
directory: on.path,
fn: () => Format.status(),
})
expect(a).toEqual([])
expect(b.length).toBeGreaterThan(0)
})
test("runs enabled checks for matching formatters in parallel", async () => {
await using tmp = await tmpdir()
const file = `${tmp.path}/test.parallel`
await Bun.write(file, "x")
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
command: Formatter.gofmt.command,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
command: Formatter.mix.command,
}
let active = 0
let max = 0
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.command = ["sh", "-c", "true"]
Formatter.mix.command = ["sh", "-c", "true"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
try {
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
} finally {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.gofmt.command = one.command
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
Formatter.mix.command = two.command
}
expect(max).toBe(2)
})
test("runs matching formatters sequentially for the same file", async () => {
await using tmp = await tmpdir({
config: {
formatter: {
first: {
command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
extensions: [".seq"],
},
second: {
command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
extensions: [".seq"],
},
},
},
})
const file = `${tmp.path}/test.seq`
await Bun.write(file, "x")
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
expect(await Bun.file(file).text()).toBe("xAB")
})
})

View File

@@ -1,151 +1,47 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { afterEach, describe, expect, test } from "bun:test"
import { Installation } from "../../src/installation"
const encoder = new TextEncoder()
const fetch0 = globalThis.fetch
function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
return Layer.succeed(HttpClient.HttpClient, client)
}
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
}),
)
})
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
}
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})
}
function testLayer(
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
spawnHandler?: (cmd: string, args: readonly string[]) => string,
) {
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
}
afterEach(() => {
globalThis.fetch = fetch0
})
describe("installation", () => {
describe("latest", () => {
test("reads release version from GitHub releases", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
test("reads release version from GitHub releases", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.2.3")
})
expect(await Installation.latest("unknown")).toBe("1.2.3")
})
test("strips v prefix from GitHub release tag", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
test("reads scoop manifest versions", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ version: "2.3.4" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
)
expect(result).toBe("4.0.0-beta.1")
})
expect(await Installation.latest("scoop")).toBe("2.3.4")
})
test("reads npm registry versions", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.5.0" }),
(cmd, args) => {
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
return ""
test("reads chocolatey feed versions", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
d: {
results: [{ Version: "3.4.5" }],
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
)) as unknown as typeof fetch
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
})
test("reads npm registry versions for bun method", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.6.0" }),
() => "",
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
})
test("reads scoop manifest versions", async () => {
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.3.4")
})
test("reads chocolatey feed versions", async () => {
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
)
expect(result).toBe("3.4.5")
})
test("reads brew formulae API versions", async () => {
const layer = testLayer(
() => jsonResponse({ versions: { stable: "2.0.0" } }),
(cmd, args) => {
// getBrewFormula: return core formula (no tap)
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.0.0")
})
test("reads brew tap info JSON via CLI", async () => {
const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
const layer = testLayer(
() => jsonResponse({}), // HTTP not used for tap formula
(cmd, args) => {
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.1.0")
})
expect(await Installation.latest("choco")).toBe("3.4.5")
})
})

View File

@@ -1,15 +1,11 @@
import { afterEach, describe, test, expect } from "bun:test"
import { Permission } from "../src/permission"
import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
describe("PermissionNext.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -17,42 +13,42 @@ describe("Permission.evaluate for permission.task", () => {
}))
test("returns ask when no match (default)", () => {
expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
})
test("returns deny for explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "deny" })
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
})
test("returns allow for explicit allow", () => {
const ruleset = createRuleset({ "code-reviewer": "allow" })
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
})
test("returns ask for explicit ask", () => {
const ruleset = createRuleset({ "code-reviewer": "ask" })
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" })
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with allow", () => {
const ruleset = createRuleset({ "orchestrator-*": "allow" })
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
})
test("matches wildcard patterns with ask", () => {
const ruleset = createRuleset({ "orchestrator-*": "ask" })
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
const globalRuleset = createRuleset({ "*": "ask" })
expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
})
test("later rules take precedence (last match wins)", () => {
@@ -60,22 +56,22 @@ describe("Permission.evaluate for permission.task", () => {
"orchestrator-*": "deny",
"orchestrator-fast": "allow",
})
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
})
test("matches global wildcard", () => {
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
})
})
describe("Permission.disabled for task tool", () => {
describe("PermissionNext.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -89,7 +85,7 @@ describe("Permission.disabled for task tool", () => {
"orchestrator-*": "allow",
"*": "deny",
})
const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
@@ -99,14 +95,14 @@ describe("Permission.disabled for task tool", () => {
"orchestrator-*": "ask",
"*": "deny",
})
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
test("task tool is disabled when global deny pattern exists", () => {
const ruleset = createRuleset({ "*": "deny" })
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
})
@@ -117,13 +113,13 @@ describe("Permission.disabled for task tool", () => {
"orchestrator-*": "deny",
general: "deny",
})
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(false)
})
test("task tool is enabled when no task rules exist (default ask)", () => {
const disabled = Permission.disabled(["task"], [])
const disabled = PermissionNext.disabled(["task"], [])
expect(disabled.has("task")).toBe(false)
})
@@ -133,7 +129,7 @@ describe("Permission.disabled for task tool", () => {
"*": "deny",
"orchestrator-coder": "allow",
})
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
// The disabled() function uses findLast and checks if the last matching rule
// has pattern: "*" and action: "deny". In this case, the last rule matching
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@@ -159,11 +155,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
},
})
})
@@ -184,11 +180,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
},
})
})
@@ -209,11 +205,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
},
})
})
@@ -236,18 +232,18 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// Verify task permissions
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Verify other tool permissions
expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
// Verify disabled tools
const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
expect(disabled.has("bash")).toBe(false)
expect(disabled.has("edit")).toBe(false)
// task is NOT disabled because disabled() uses findLast, and the last rule
@@ -274,16 +270,16 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
// Since "*": "deny" is the last rule, disabled() finds it with findLast
// and sees pattern: "*" with action: "deny", so task is disabled
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
},
})
@@ -305,17 +301,17 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
// Other agents still denied by the earlier "*" deny
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
// So the task tool is NOT disabled (even though most subagents are denied)
const disabled = Permission.disabled(["task"], ruleset)
const disabled = PermissionNext.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(false)
},
})

View File

@@ -1,7 +1,11 @@
import { afterEach, test, expect } from "bun:test"
import os from "os"
import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { Permission } from "../../src/permission"
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 { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -12,8 +16,8 @@ afterEach(async () => {
})
async function rejectAll(message?: string) {
for (const req of await Permission.list()) {
await Permission.reply({
for (const req of await PermissionNext.list()) {
await PermissionNext.reply({
requestID: req.id,
reply: "reject",
message,
@@ -23,22 +27,22 @@ async function rejectAll(message?: string) {
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
const list = await Permission.list()
const list = await PermissionNext.list()
if (list.length === count) return list
await Bun.sleep(0)
}
return Permission.list()
return PermissionNext.list()
}
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
const result = Permission.fromConfig({ bash: "allow" })
const result = PermissionNext.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
@@ -46,7 +50,7 @@ test("fromConfig - object value converts to rules array", () => {
})
test("fromConfig - mixed string and object values", () => {
const result = Permission.fromConfig({
const result = PermissionNext.fromConfig({
bash: { "*": "allow", rm: "deny" },
edit: "allow",
webfetch: "ask",
@@ -60,51 +64,51 @@ test("fromConfig - mixed string and object values", () => {
})
test("fromConfig - empty object", () => {
const result = Permission.fromConfig({})
const result = PermissionNext.fromConfig({})
expect(result).toEqual([])
})
test("fromConfig - expands tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME without trailing slash", () => {
const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("fromConfig - does not expand tilde in middle of path", () => {
const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
test("fromConfig - expands exact tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("evaluate - matches expanded tilde pattern", () => {
const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
test("evaluate - matches expanded $HOME pattern", () => {
const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
// merge tests
test("merge - simple concatenation", () => {
const result = Permission.merge(
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -115,7 +119,7 @@ test("merge - simple concatenation", () => {
})
test("merge - adds new permission", () => {
const result = Permission.merge(
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }],
)
@@ -126,7 +130,7 @@ test("merge - adds new permission", () => {
})
test("merge - concatenates rules for same permission", () => {
const result = Permission.merge(
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -137,7 +141,7 @@ test("merge - concatenates rules for same permission", () => {
})
test("merge - multiple rulesets", () => {
const result = Permission.merge(
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }],
@@ -150,12 +154,12 @@ test("merge - multiple rulesets", () => {
})
test("merge - empty ruleset does nothing", () => {
const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("merge - preserves rule order", () => {
const result = Permission.merge(
const result = PermissionNext.merge(
[
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -171,40 +175,40 @@ test("merge - preserves rule order", () => {
test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = PermissionNext.merge(defaults, config)
// Config's bash allow should override default ask
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults)
expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
})
test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask"
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = PermissionNext.merge(defaults, config)
// Config's ask should override default allow
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
})
// evaluate tests
test("evaluate - exact pattern match", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching rule wins", () => {
const result = Permission.evaluate("bash", "rm", [
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
@@ -212,7 +216,7 @@ test("evaluate - last matching rule wins", () => {
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = Permission.evaluate("bash", "rm", [
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -220,12 +224,14 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
})
test("evaluate - glob pattern match", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - last matching glob wins", () => {
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" },
])
@@ -234,7 +240,7 @@ test("evaluate - last matching glob wins", () => {
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
])
@@ -242,29 +248,31 @@ test("evaluate - order matters for specificity", () => {
})
test("evaluate - unknown permission returns ask", () => {
const result = Permission.evaluate("unknown_tool", "anything", [
const result = PermissionNext.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - empty ruleset returns ask", () => {
const result = Permission.evaluate("bash", "rm", [])
const result = PermissionNext.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - no matching pattern returns ask", () => {
const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
const result = PermissionNext.evaluate("edit", "etc/passwd", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - empty rules array returns ask", () => {
const result = Permission.evaluate("bash", "rm", [])
const result = PermissionNext.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
const result = Permission.evaluate("edit", "src/secret.ts", [
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
@@ -273,7 +281,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
})
test("evaluate - non-matching patterns are skipped", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
@@ -282,7 +290,7 @@ test("evaluate - non-matching patterns are skipped", () => {
})
test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = Permission.evaluate("bash", "/bin/rm", [
const result = PermissionNext.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
])
@@ -290,7 +298,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
})
test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = Permission.evaluate("bash", "/bin/rm", [
const result = PermissionNext.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -300,24 +308,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
const result = Permission.evaluate("mcp_server_tool", "anything", [
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
const result = Permission.evaluate("bash", "rm", [
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -325,7 +333,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
})
test("evaluate - wildcard permission does not match when specific exists", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
@@ -333,7 +341,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
})
test("evaluate - multiple matching permission patterns combine rules", () => {
const result = Permission.evaluate("mcp_dangerous", "anything", [
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
@@ -342,7 +350,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = Permission.evaluate("unknown_tool", "anything", [
const result = PermissionNext.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -351,7 +359,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win
const result = Permission.evaluate("bash", "rm", [
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
@@ -360,22 +368,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
})
test("evaluate - merges multiple rulesets", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied
const result = Permission.evaluate("bash", "rm", config, approved)
const result = PermissionNext.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
// disabled tests
test("disabled - returns empty set when all tools allowed", () => {
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0)
})
test("disabled - disables tool when denied", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -388,7 +396,7 @@ test("disabled - disables tool when denied", () => {
})
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -403,7 +411,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
})
test("disabled - does not disable when partially denied", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "allow" },
@@ -414,14 +422,14 @@ test("disabled - does not disable when partially denied", () => {
})
test("disabled - does not disable when action is ask", () => {
const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0)
})
test("disabled - does not disable when specific allow after wildcard deny", () => {
// Tool is NOT disabled because a specific allow after wildcard deny means
// there's at least some usage allowed
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -432,7 +440,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
})
test("disabled - does not disable when wildcard allow after deny", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "rm *", action: "deny" },
@@ -443,7 +451,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
})
test("disabled - disables multiple tools", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash", "edit", "webfetch"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -457,14 +465,14 @@ test("disabled - disables multiple tools", () => {
})
test("disabled - wildcard permission denies all tools", () => {
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
test("disabled - specific allow overrides wildcard deny", () => {
const result = Permission.disabled(
const result = PermissionNext.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "deny" },
@@ -483,7 +491,7 @@ test("ask - resolves immediately when action is allow", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await Permission.ask({
const result = await PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -502,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
Permission.ask({
PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
@@ -510,7 +518,7 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
).rejects.toBeInstanceOf(Permission.DeniedError)
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
},
})
})
@@ -520,7 +528,7 @@ test("ask - returns pending promise when action is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = Permission.ask({
const promise = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -542,7 +550,7 @@ test("ask - adds request to pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -555,7 +563,7 @@ test("ask - adds request to pending list", async () => {
ruleset: [],
})
const list = await Permission.list()
const list = await PermissionNext.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -580,12 +588,12 @@ test("ask - publishes asked event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
let seen: Permission.Request | undefined
const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
let seen: PermissionNext.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
seen = event.properties
})
const ask = Permission.ask({
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -598,7 +606,7 @@ test("ask - publishes asked event", async () => {
ruleset: [],
})
expect(await Permission.list()).toHaveLength(1)
expect(await PermissionNext.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -620,7 +628,7 @@ test("reply - once resolves the pending ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
const askPromise = PermissionNext.ask({
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -632,7 +640,7 @@ test("reply - once resolves the pending ask", async () => {
await waitForPending(1)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
})
@@ -647,7 +655,7 @@ test("reply - reject throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
const askPromise = PermissionNext.ask({
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -659,12 +667,12 @@ test("reply - reject throws RejectedError", async () => {
await waitForPending(1)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
@@ -674,7 +682,7 @@ test("reply - reject with message throws CorrectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
const ask = PermissionNext.ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -686,14 +694,14 @@ test("reply - reject with message throws CorrectedError", async () => {
await waitForPending(1)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
@@ -704,7 +712,7 @@ test("reply - always persists approval and resolves", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
const askPromise = PermissionNext.ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -716,7 +724,7 @@ test("reply - always persists approval and resolves", async () => {
await waitForPending(1)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
})
@@ -729,7 +737,7 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path,
fn: async () => {
// Stored approval should allow without asking
const result = await Permission.ask({
const result = await PermissionNext.ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
@@ -747,7 +755,7 @@ test("reply - reject cancels all pending for same session", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise1 = Permission.ask({
const askPromise1 = PermissionNext.ask({
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -757,7 +765,7 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [],
})
const askPromise2 = Permission.ask({
const askPromise2 = PermissionNext.ask({
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
@@ -774,14 +782,14 @@ test("reply - reject cancels all pending for same session", async () => {
const result2 = askPromise2.catch((e) => e)
// Reject the first one
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})
// Both should be rejected
expect(await result1).toBeInstanceOf(Permission.RejectedError)
expect(await result2).toBeInstanceOf(Permission.RejectedError)
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
@@ -791,7 +799,7 @@ test("reply - always resolves matching pending requests in same session", async
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = Permission.ask({
const a = PermissionNext.ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -801,7 +809,7 @@ test("reply - always resolves matching pending requests in same session", async
ruleset: [],
})
const b = Permission.ask({
const b = PermissionNext.ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -813,14 +821,14 @@ test("reply - always resolves matching pending requests in same session", async
await waitForPending(2)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
expect(await Permission.list()).toHaveLength(0)
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
@@ -830,7 +838,7 @@ test("reply - always keeps other session pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = Permission.ask({
const a = PermissionNext.ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
@@ -840,7 +848,7 @@ test("reply - always keeps other session pending", async () => {
ruleset: [],
})
const b = Permission.ask({
const b = PermissionNext.ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
@@ -852,13 +860,13 @@ test("reply - always keeps other session pending", async () => {
await waitForPending(2)
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test6a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll()
await b.catch(() => {})
@@ -871,7 +879,7 @@ test("reply - publishes replied event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
const ask = PermissionNext.ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -887,14 +895,14 @@ test("reply - publishes replied event", async () => {
| {
sessionID: SessionID
requestID: PermissionID
reply: Permission.Reply
reply: PermissionNext.Reply
}
| undefined
const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
seen = event.properties
})
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
@@ -910,141 +918,16 @@ test("reply - publishes replied event", async () => {
})
})
test("permission requests stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const a = Instance.provide({
directory: one.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const b = Instance.provide({
directory: two.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
metadata: {},
always: [],
ruleset: [],
}),
})
const onePending = await Instance.provide({
directory: one.path,
fn: () => waitForPending(1),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => waitForPending(1),
})
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
await Instance.provide({
directory: one.path,
fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
})
await Instance.provide({
directory: two.path,
fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
})
await a.catch(() => {})
await b.catch(() => {})
})
test("pending permission rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
test("pending permission rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Permission.reply({
await PermissionNext.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
expect(await Permission.list()).toHaveLength(0)
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
@@ -1055,7 +938,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
Permission.ask({
PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -1066,7 +949,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
).rejects.toBeInstanceOf(Permission.DeniedError)
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
},
})
})
@@ -1076,7 +959,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await Permission.ask({
const result = await PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
@@ -1094,7 +977,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const err = await Permission.ask({
const err = await PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -1109,8 +992,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
(err) => err,
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(await Permission.list()).toHaveLength(0)
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
@@ -1121,8 +1004,8 @@ test("ask - abort should clear pending request", async () => {
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
const ask = Permission.runPromise(
(svc) =>
const ask = runtime.runPromise(
S.Service.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -1131,6 +1014,7 @@ test("ask - abort should clear pending request", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal },
)
@@ -1139,7 +1023,7 @@ test("ask - abort should clear pending request", async () => {
await ask.catch(() => {})
try {
expect(await Permission.list()).toHaveLength(0)
expect(await PermissionNext.list()).toHaveLength(0)
} finally {
await rejectAll()
}

View File

@@ -31,26 +31,15 @@ describe("plugin.auth-override", () => {
},
})
await using plain = await tmpdir()
const methods = await Instance.provide({
await Instance.provide({
directory: tmp.path,
fn: async () => {
return ProviderAuth.methods()
const methods = await ProviderAuth.methods()
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
},
})
const plainMethods = await Instance.provide({
directory: plain.path,
fn: async () => {
return ProviderAuth.methods()
},
})
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
})

View File

@@ -25,8 +25,8 @@ function withVcs(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => {
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await rt.runPromise(Vcs.Service.use((s) => s.init()))
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(Vcs.Service.use(() => Effect.void))
await Bun.sleep(500)
await body(rt)
},
@@ -67,9 +67,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
// ---------------------------------------------------------------------------
describeVcs("Vcs", () => {
afterEach(async () => {
await Instance.disposeAll()
})
afterEach(() => Instance.disposeAll())
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })

View File

@@ -1,13 +1,12 @@
import { test, expect, describe } from "bun:test"
import { test, expect } from "bun:test"
import path from "path"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Global } from "../../src/global"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
test("GitLab Duo: loads provider with API key from environment", async () => {
await using tmp = await tmpdir({
@@ -288,121 +287,3 @@ test("GitLab Duo: has multiple agentic chat models available", async () => {
},
})
})
describe("GitLab Duo: workflow model routing", () => {
test("duo-workflow-* model routes through workflowChat", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const gitlab = providers[ProviderID.gitlab]
expect(gitlab).toBeDefined()
gitlab.models["duo-workflow-sonnet-4-6"] = {
id: ModelID.make("duo-workflow-sonnet-4-6"),
providerID: ProviderID.make("gitlab"),
name: "Agent Platform (Claude Sonnet 4.6)",
family: "",
api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
status: "active",
headers: {},
options: { workflowRef: "claude_sonnet_4_6" },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200000, output: 64000 },
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: {},
}
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
expect(model).toBeDefined()
expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
},
})
})
test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
expect(model).toBeDefined()
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
},
})
})
test("model.options merged with provider.options in getLanguage", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const gitlab = providers[ProviderID.gitlab]
expect(gitlab.options?.featureFlags).toBeDefined()
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
expect(model).toBeDefined()
expect(model.options).toBeDefined()
},
})
})
})
describe("GitLab Duo: static models", () => {
test("static duo-chat models always present regardless of discovery", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const models = Object.keys(providers[ProviderID.gitlab].models)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")
expect(models).toContain("duo-chat-opus-4-5")
},
})
})
})

View File

@@ -320,134 +320,3 @@ test("list - returns empty when no pending", async () => {
},
})
})
test("questions stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const p1 = Instance.provide({
directory: one.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
question: "Question 1?",
header: "Q1",
options: [{ label: "A", description: "A" }],
},
],
}),
})
const p2 = Instance.provide({
directory: two.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
question: "Question 2?",
header: "Q2",
options: [{ label: "B", description: "B" }],
},
],
}),
})
const onePending = await Instance.provide({
directory: one.path,
fn: () => Question.list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => Question.list(),
})
expect(onePending.length).toBe(1)
expect(twoPending.length).toBe(1)
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
await Instance.provide({
directory: one.path,
fn: () => Question.reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => Question.reject(twoPending[0].id),
})
await p1.catch(() => {})
await p2.catch(() => {})
})
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
question: "Dispose me?",
header: "Dispose",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})
test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
question: "Reload me?",
header: "Reload",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})

View File

@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
const originalActive = Account.active
const originalConfigGet = Config.get
Account.active = mock(async () => undefined)
Account.active = mock(() => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try {
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(async () => ({
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(async () => ({
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",

View File

@@ -1,14 +1,10 @@
import { afterEach, test, expect } from "bun:test"
import { test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
afterEach(async () => {
await Instance.disposeAll()
})
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
await fs.mkdir(skillDir, { recursive: true })

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