Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline
97799f347e fix: ensure that plugins can register providers with config changes 2026-03-20 00:36:00 -05:00
85 changed files with 3019 additions and 4041 deletions

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:",
@@ -586,7 +586,6 @@
],
"patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
@@ -1110,6 +1109,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 +3031,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 +3783,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 +4245,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 +5059,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 +5459,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 +5535,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 +6285,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-xq0W2Ym0AzANLXnLyAL+IUwrFm0MKXwkJVdENowoPyY=",
"aarch64-linux": "sha256-RtpiGZXk+BboD9MjBetq5sInIbH/OPkLVNSFgN/0ehY=",
"aarch64-darwin": "sha256-cX6y262OzqRicH4m0/u1DCsMkpJfzCUOOBFQqtQLvls=",
"x86_64-darwin": "sha256-K4UmRKiEfKkvVeKUB85XjHJ1jf0ZUnjL0dWvx9TD4pI="
}
}

View File

@@ -113,7 +113,6 @@
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch"
}
}

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

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

@@ -89,9 +89,8 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"gitlab-ai-provider": "5.2.2",
"opencode-gitlab-auth": "2.0.0",
"@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",
@@ -105,6 +104,7 @@
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -126,7 +126,7 @@ Done now:
Still open and likely worth migrating:
- [x] `Plugin`
- [ ] `Plugin`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`

View File

@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
@@ -24,30 +24,10 @@ import {
UserCode,
} from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Info
account: Account
orgs: readonly Org[]
}
@@ -128,10 +108,10 @@ const mapAccountServiceError =
),
)
export namespace Account {
export namespace AccountEffect {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
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>

View File

@@ -1,24 +1,31 @@
import { Effect, Option } from "effect"
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountEffect,
OrgID,
} from "./effect"
export { AccessToken, AccountID, OrgID } from "./effect"
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(S.Service.use(f))
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f))
}
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(S.Service.use(f))
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f))
}
export namespace Account {
export const Info = Model
export type Info = Model
export const Account = AccountSchema
export type Account = AccountSchema
export function active(): Info | undefined {
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}

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

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

@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace Auth {
export namespace AuthEffect {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>

View File

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

View File

@@ -1,5 +1,5 @@
import z from "zod"
import type { ZodObject, ZodRawShape } from "zod"
import type { ZodType } 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 ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,

View File

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

View File

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

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

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

View File

@@ -1,16 +1,15 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { File } from "@/file/service"
import { FileTime } from "@/file/time-service"
import { File } from "@/file"
import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format/service"
import { Permission } from "@/permission/service"
import { Format } from "@/format"
import { PermissionNext } from "@/permission"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth-service"
import { Question } from "@/question/service"
import { Skill } from "@/skill/service"
import { Snapshot } from "@/snapshot/service"
import { Plugin } from "@/plugin"
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"
@@ -18,7 +17,7 @@ export { InstanceContext } from "./instance-context"
export type InstanceServices =
| Question.Service
| Permission.Service
| PermissionNext.Service
| ProviderAuth.Service
| FileWatcher.Service
| Vcs.Service
@@ -27,7 +26,6 @@ export type InstanceServices =
| File.Service
| Skill.Service
| Snapshot.Service
| Plugin.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
@@ -38,17 +36,16 @@ export type InstanceServices =
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Question.layer,
Permission.layer,
ProviderAuth.defaultLayer,
FileWatcher.layer,
Vcs.layer,
FileTime.layer,
Format.layer,
File.layer,
Skill.defaultLayer,
Snapshot.defaultLayer,
Plugin.layer,
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))
}

View File

@@ -1,19 +1,17 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Account } from "@/account/effect"
import { Auth } from "@/auth/effect"
import { AccountEffect } from "@/account/effect"
import { AuthEffect } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { Installation } from "@/installation"
import { Truncate } from "@/tool/truncate-effect"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
Account.defaultLayer, //
Installation.defaultLayer,
Truncate.defaultLayer,
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(Auth.layer)),
).pipe(Layer.provideMerge(AuthEffect.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

View File

@@ -1,40 +1,695 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { File as S } from "./service"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = S.Info
export type Info = S.Info
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export const Node = S.Node
export type Node = S.Node
export type Info = z.infer<typeof Info>
export const Content = S.Content
export type Content = S.Content
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Event = S.Event
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export function init() {
return runPromiseInstance(S.Service.use((svc) => svc.init()))
return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
return runPromiseInstance(S.Service.use((svc) => svc.status()))
return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
return runPromiseInstance(Service.use((svc) => svc.search(input)))
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
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 next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(instance.directory, full),
}
})
})
})
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)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const 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
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
)
}

View File

@@ -1,674 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
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 next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(instance.directory, full),
}
})
})
})
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)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const 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
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
).pipe(Layer.fresh)
}

View File

@@ -1,93 +0,0 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie, Layer.fresh)
}

View File

@@ -1,28 +1,110 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { FileTime as S } from "./time-service"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
export type Stamp = S.Stamp
const log = Log.create({ service: "file.time" })
export type Interface = S.Interface
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
export const Service = S.Service
export const layer = S.layer
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
)
export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
}
export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(S.Service.use((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 runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
}
}

View File

@@ -137,5 +137,5 @@ export namespace FileWatcher {
return Effect.succeed(Service.of({}))
}),
),
).pipe(Layer.orDie, Layer.fresh)
)
}

View File

@@ -1,16 +1,157 @@
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Format as S } from "./service"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format {
export const Status = S.Status
export type Status = S.Status
const log = Log.create({ service: "format" })
export type Interface = S.Interface
export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export const Service = S.Service
export const layer = S.layer
export interface Interface {
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
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,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ status })
}),
)
export async function status() {
return runPromiseInstance(S.Service.use((s) => s.status()))
return runPromiseInstance(Service.use((s) => s.status()))
}
}

View File

@@ -1,152 +0,0 @@
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file/service"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export interface Interface {
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
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,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ status })
}),
).pipe(Layer.fresh)
}

View File

@@ -1,13 +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 { 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
@@ -17,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(
@@ -44,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"
@@ -56,300 +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:
throw new Error(`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(),
}),
)
// Legacy adapters — dynamic import avoids circular dependency since
// foundational modules (db.ts, provider/models.ts) import Installation
// at load time, and runtime transitively loads those same modules.
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
const { runtime } = await import("@/effect/runtime")
return runtime.runPromise(Service.use(f))
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 function info(): Promise<Info> {
return runPromise((svc) => svc.info())
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 function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
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 function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
export async function latest(installMethod?: Method) {
const detectedMethod = installMethod || (await method())
export function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
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,52 +1,292 @@
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { Permission as S } from "./service"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace PermissionNext {
export const Action = S.Action
export type Action = S.Action
const log = Log.create({ service: "permission" })
export const Rule = S.Rule
export type Rule = S.Rule
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Ruleset = S.Ruleset
export type Ruleset = S.Ruleset
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Request = S.Request
export type Request = S.Request
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Reply = S.Reply
export type Reply = S.Reply
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Approval = S.Approval
export type Approval = z.infer<typeof S.Approval>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Event = S.Event
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
export type Error = S.Error
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export const AskInput = S.AskInput
export const ReplyInput = S.ReplyInput
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export type Interface = S.Interface
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export const Service = S.Service
export const layer = S.layer
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export const evaluate = S.evaluate
export const fromConfig = S.fromConfig
export const merge = S.merge
export const disabled = S.disabled
export type Error = DeniedError | RejectedError | CorrectedError
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
return pattern
}
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
)
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
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(S.Service.use((s) => s.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> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
}

View File

@@ -1,282 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
).pipe(Layer.fresh)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
return pattern
}
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
)
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
}

View File

@@ -1,206 +1,144 @@
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
export interface Interface {
readonly trigger: <
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(
name: Name,
input: Input,
output: Output,
) => Effect.Effect<Output>
readonly list: () => Effect.Effect<Hooks[]>
readonly init: () => Effect.Effect<void>
}
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: Instance.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
})
const config = await Config.get()
const hooks: Hooks[] = []
const input: PluginInput = {
client,
project: Instance.project,
worktree: Instance.worktree,
directory: Instance.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const hooks: Hooks[] = []
let task: Promise<void> | undefined
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
const load = Effect.fn("Plugin.load")(function* () {
yield* Effect.promise(async () => {
const [{ Config }, { Server }, codex, copilot, gitlab] = await Promise.all([
import("../config/config"),
import("../server/server"),
import("./codex"),
import("./copilot"),
import("opencode-gitlab-auth"),
])
const internal: PluginInstance[] = [codex.CodexAuthPlugin, copilot.CopilotAuthPlugin, gitlab.gitlabAuthPlugin]
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: instance.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
let plugins = config.plugin ?? []
if (plugins.length) await Config.waitForDependencies()
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
const config = await Config.get()
const input: PluginInput = {
client,
project: instance.project,
worktree: instance.worktree,
directory: instance.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
for (const plugin of internal) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
let plugins = config.plugin ?? []
if (plugins.length) await Config.waitForDependencies()
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
void import("../session").then(({ Session }) =>
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
}),
)
return ""
})
if (!plugin) continue
}
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
void import("../session").then(({ Session }) =>
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
}),
)
})
return ""
})
if (!plugin) continue
}
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
})
const ensure = Effect.fn("Plugin.ensure")(function* () {
yield* Effect.promise(() => {
task ??= Effect.runPromise(
load().pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))),
)
return task
})
})
const trigger = Effect.fn("Plugin.trigger")(function* <
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output) {
if (!name) return output
yield* ensure()
yield* Effect.promise(async () => {
for (const hook of hooks) {
const fn = hook[name]
if (!fn) continue
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
// give up.
// try-counter: 2
await fn(input, output)
}
})
return output
})
const list = Effect.fn("Plugin.list")(function* () {
yield* ensure()
return hooks
})
const init = Effect.fn("Plugin.init")(function* () {
yield* ensure()
yield* Effect.promise(async () => {
const { Config } = await import("../config/config")
const config = await Config.get()
for (const hook of hooks) {
await (hook as any).config?.(config)
}
Bus.subscribeAll(async (input) => {
for (const hook of hooks) {
hook["event"]?.({
event: input,
})
}
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
})
}
return Service.of({ trigger, list, init })
}),
).pipe(Layer.fresh)
async function run<A, E>(effect: Effect.Effect<A, E, Service>) {
const { runPromiseInstance } = await import("@/effect/runtime")
return runPromiseInstance(effect)
}
return {
hooks,
input,
}
})
export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
return run(Service.use((svc) => svc.trigger(name, input, output)))
if (!name) return output
for (const hook of await state().then((x) => x.hooks)) {
const fn = hook[name]
if (!fn) continue
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
// give up.
// try-counter: 2
await fn(input, output)
}
return output
}
export async function list(): Promise<Hooks[]> {
return run(Service.use((svc) => svc.list()))
export async function list() {
return state().then((x) => x.hooks)
}
export async function init() {
return run(Service.use((svc) => svc.init()))
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
for (const hook of hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
for (const hook of hooks) {
hook["event"]?.({
event: input,
})
}
})
}
}

View File

@@ -79,5 +79,5 @@ export namespace Vcs {
}),
})
}),
).pipe(Layer.fresh)
)
}

View File

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

View File

@@ -1,30 +1,222 @@
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/effect"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
import z from "zod"
import { ProviderAuth as S } from "./auth-service"
export namespace ProviderAuth {
export const Method = S.Method
export type Method = S.Method
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: z
.array(
z.union([
z.object({
type: z.literal("text"),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
z.object({
type: z.literal("select"),
key: z.string(),
message: z.string(),
options: z.array(
z.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
}),
),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
]),
)
.optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export const Authorization = S.Authorization
export type Authorization = S.Authorization
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = S.OauthMissing
export const OauthCodeMissing = S.OauthCodeMissing
export const OauthCallbackFailed = S.OauthCallbackFailed
export const ValidationFailed = S.ValidationFailed
export type Error = S.Error
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
export type Interface = S.Interface
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({ providerID: ProviderID.zod }),
)
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const ValidationFailed = NamedError.create(
"ProviderAuthValidationFailed",
z.object({
field: z.string(),
message: z.string(),
}),
)
export type Error =
| Auth.AuthError
| InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
const plugins = await mod.Plugin.list()
return Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
)
})
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuth.methods")(function* () {
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
type: method.type,
label: method.label,
prompts: method.prompts?.map((prompt) => {
if (prompt.type === "select") {
return {
type: "select" as const,
key: prompt.key,
message: prompt.message,
options: prompt.options,
when: prompt.when,
}
}
return {
type: "text" as const,
key: prompt.key,
message: prompt.message,
placeholder: prompt.placeholder,
when: prompt.when,
}
}),
}),
),
)
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) {
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
if (method.prompts && input.inputs) {
for (const prompt of method.prompts) {
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
const error = prompt.validate(input.inputs[prompt.key])
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
}
}
}
const result = yield* Effect.promise(() => method.authorize(input.inputs))
pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID
method: number
code?: string
}) {
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
yield* auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
return Service.of({ methods, authorize, callback })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
export async function methods() {
return runPromiseInstance(S.Service.use((svc) => svc.methods()))
return runPromiseInstance(Service.use((svc) => svc.methods()))
}
export const authorize = fn(
@@ -33,8 +225,7 @@ export namespace ProviderAuth {
method: z.number(),
inputs: z.record(z.string(), z.string()).optional(),
}),
async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
)
export const callback = fn(
@@ -43,6 +234,6 @@ export namespace ProviderAuth {
method: z.number(),
code: z.string().optional(),
}),
async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
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")
@@ -953,8 +866,16 @@ export namespace Provider {
providers[providerID] = mergeDeep(existing, provider)
return
}
const match = database[providerID]
if (!match) return
const match =
database[providerID] ??
({
id: providerID,
name: providerID,
source: "custom",
env: [],
options: {},
models: {},
} satisfies Info)
// @ts-expect-error
providers[providerID] = mergeDeep(match, provider)
}
@@ -1096,7 +1017,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 +1078,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 +1258,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,49 +1,193 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import type { MessageID, SessionID } from "@/session/schema"
import type { QuestionID } from "./schema"
import { Question as S } from "./service"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
export namespace Question {
export const Option = S.Option
export type Option = S.Option
// Schemas
export const Info = S.Info
export type Info = S.Info
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Request = S.Request
export type Request = S.Request
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Answer = S.Answer
export type Answer = S.Answer
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Reply = S.Reply
export type Reply = S.Reply
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Event = S.Event
export const RejectedError = S.RejectedError
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export type Interface = S.Interface
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export const Service = S.Service
export const layer = S.layer
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
// Service
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
)
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromiseInstance(S.Service.use((s) => s.ask(input)))
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
return runPromiseInstance(S.Service.use((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 runPromiseInstance(S.Service.use((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 runPromiseInstance(S.Service.use((s) => s.list()))
export async function list(): Promise<Request[]> {
return runPromiseInstance(Service.use((svc) => svc.list()))
}
}

View File

@@ -1,172 +0,0 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
export namespace Question {
// Schemas
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
// Service
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
).pipe(Layer.fresh)
}

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

@@ -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"
@@ -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: [

View File

@@ -7,7 +7,7 @@ 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"
@@ -15,10 +15,14 @@ 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]

View File

@@ -1,238 +0,0 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission/service"
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 = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills).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 })
}),
).pipe(Layer.fresh)
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = 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")
}
}

View File

@@ -1,35 +1,255 @@
import { runPromiseInstance } from "@/effect/runtime"
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 { Skill as S } from "./service"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { PermissionNext } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { 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 {
export const Info = S.Info
export type Info = S.Info
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 InvalidError = S.InvalidError
export const NameMismatchError = S.NameMismatchError
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 type Interface = S.Interface
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
export const fmt = S.fmt
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
const add = async (state: State, match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
if (state.skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
})
}
// TODO: Migrate to Effect
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills).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),
)
export async function get(name: string) {
return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
return runPromiseInstance(Service.use((skill) => skill.get(name)))
}
export async function all() {
return runPromiseInstance(S.Service.use((skill) => skill.all()))
return runPromiseInstance(Service.use((skill) => skill.all()))
}
export async function dirs() {
return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
return runPromiseInstance(Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
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

@@ -1,44 +1,349 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Snapshot as S } from "./service"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
export namespace Snapshot {
export const Patch = S.Patch
export type Patch = S.Patch
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export const FileDiff = S.FileDiff
export type FileDiff = S.FileDiff
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export async function cleanup() {
return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
}
export async function track() {
return runPromiseInstance(S.Service.use((svc) => svc.track()))
return runPromiseInstance(Service.use((svc) => svc.track()))
}
export async function patch(hash: string) {
return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
}
export async function restore(snapshot: string) {
return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
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"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
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", 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),
}),
),
)
// 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 },
)
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),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
}

View File

@@ -1,320 +0,0 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
export namespace Snapshot {
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
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", 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),
}),
),
)
// 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 },
)
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 })
}),
).pipe(Layer.fresh)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
}

View File

@@ -9,7 +9,7 @@ import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace Truncate {
export namespace TruncateEffect {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)

View File

@@ -1,6 +1,6 @@
import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime"
import { Truncate as S } from "./truncate-effect"
import { TruncateEffect as S } from "./truncate-effect"
export namespace Truncate {
export const MAX_LINES = S.MAX_LINES

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/effect"
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,128 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
import { Auth } from "../../src/auth/effect"
import { Instances } from "../../src/effect/instances"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
import { Vcs } from "../../src/project/vcs"
import { Question } from "../../src/question"
import { tmpdir } from "../fixture/fixture"
/**
* Integration tests for the Effect runtime and LayerMap-based instance system.
*
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
* so it is always rebuilt per directory, while shared dependencies are provided
* outside the fresh boundary and remain memoizable.
*
* These tests verify the invariants using object identity (===) on the real
* production services — not mock services or return-value checks.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
describe("effect/runtime", () => {
afterEach(async () => {
await Instance.disposeAll()
})
test("global services are shared across directories", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// Auth is a global service — it should be the exact same object
// regardless of which directory we're in.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// ProviderAuth depends on Auth via defaultLayer.
// The instance service itself should be different per directory,
// but the underlying Auth should be shared.
const paOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(ProviderAuth.Service),
})
const paTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(ProviderAuth.Service),
})
// Different directories → different ProviderAuth instances.
expect(paOne).not.toBe(paTwo)
// But the global Auth is the same object in both.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services are shared within the same directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
},
})
})
test("different directories get different service instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const vcsOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(Vcs.Service),
})
const vcsTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(Vcs.Service),
})
expect(vcsOne).not.toBe(vcsTwo)
})
test("disposal rebuilds services with a new instance", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await grabInstance(Question.Service)
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
const after = await grabInstance(Question.Service)
expect(after).not.toBe(before)
},
})
})
})

View File

@@ -16,7 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers
// ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: Record<string, unknown> } }
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
@@ -40,18 +40,18 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (
if (done) return
if (evt.directory !== directory) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
const props = evt.payload.properties as WatcherEvent
if (!check(props)) return
hit(props)
if (!check(evt.payload.properties)) return
hit(evt.payload.properties)
}
GlobalBus.on("event", on)
return () => {
function cleanup() {
if (done) return
done = true
GlobalBus.off("event", on)
}
GlobalBus.on("event", on)
return cleanup
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {

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

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

@@ -32933,7 +32933,7 @@
"gitlab": {
"id": "gitlab",
"env": ["GITLAB_TOKEN"],
"npm": "gitlab-ai-provider",
"npm": "@gitlab/gitlab-ai-provider",
"name": "GitLab Duo",
"doc": "https://docs.gitlab.com/user/duo_agent_platform/",
"models": {

View File

@@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
@@ -139,7 +139,7 @@ describe("Truncate", () => {
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
it.effect("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () {
@@ -152,7 +152,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* TruncateSvc.Service.use((s) => s.cleanup())
yield* TruncateEffect.Service.use((s) => s.cleanup())
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)

View File

@@ -4,15 +4,15 @@ import {
createMemo,
createSignal,
For,
Index,
Match,
onMount,
Show,
Switch,
onCleanup,
Index,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, unwrap } from "solid-js/store"
import stripAnsi from "strip-ansi"
import { Dynamic } from "solid-js/web"
import {
@@ -481,6 +481,15 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
return toolDefaultOpen(part.tool, shell, edit)
}
function bindMessage<T extends MessageType>(input: T) {
const data = useData()
const base = structuredClone(unwrap(input)) as T
return createMemo(() => {
const next = data.store.message?.[base.sessionID]?.find((item) => item.id === base.id)
return (next as T | undefined) ?? base
})
}
export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
@@ -521,62 +530,55 @@ export function AssistantParts(props: {
return (
<Index each={grouped()}>
{(entryAccessor) => {
const entryType = createMemo(() => entryAccessor().type)
{(entry) => {
const kind = createMemo(() => entry().type)
const parts = createMemo(
() => {
const value = entry()
if (value.type !== "context") return emptyTools
return value.refs
.map((ref) => part().get(ref.messageID)?.get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
{ equals: same },
)
const busy = createMemo(() => props.working && last() === entry().key)
const message = createMemo(() => {
const value = entry()
if (value.type !== "part") return
return msgs().get(value.ref.messageID)
})
const item = createMemo(() => {
const value = entry()
if (value.type !== "part") return
return part().get(value.ref.messageID)?.get(value.ref.partID)
})
const ready = createMemo(() => {
if (kind() !== "part") return
const msg = message()
const value = item()
if (!msg || !value) return
return { msg, value }
})
return (
<Switch>
<Match when={entryType() === "context"}>
{(() => {
const parts = createMemo(
() => {
const entry = entryAccessor()
if (entry.type !== "context") return emptyTools
return entry.refs
.map((ref) => part().get(ref.messageID)?.get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
{ equals: same },
)
const busy = createMemo(() => props.working && last() === entryAccessor().key)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} busy={busy()} />
</Show>
)
})()}
</Match>
<Match when={entryType() === "part"}>
{(() => {
const message = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return msgs().get(entry.ref.messageID)
})
const item = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return part().get(entry.ref.messageID)?.get(entry.ref.partID)
})
return (
<Show when={message()}>
<Show when={item()}>
<Part
part={item()!}
message={message()!}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
</Show>
</Show>
)
})()}
</Match>
</Switch>
<>
<Show when={kind() === "context" && parts().length > 0}>
<ContextToolGroup parts={parts()} busy={busy()} />
</Show>
<Show when={ready()}>
{(ready) => (
<Part
part={ready().value}
message={ready().msg}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(ready().value, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
)}
</Show>
</>
)
}}
</Index>
@@ -688,25 +690,22 @@ export function registerPartComponent(type: string, component: PartComponent) {
}
export function Message(props: MessageProps) {
return (
<Switch>
<Match when={props.message.role === "user" && props.message}>
{(userMessage) => (
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
)}
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
showReasoningSummaries={props.showReasoningSummaries}
/>
)}
</Match>
</Switch>
)
if (props.message.role === "user") {
return <UserMessageDisplay message={props.message as UserMessage} parts={props.parts} actions={props.actions} />
}
if (props.message.role === "assistant") {
return (
<AssistantMessageDisplay
message={props.message as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
showReasoningSummaries={props.showReasoningSummaries}
/>
)
}
return undefined
}
export function AssistantMessageDisplay(props: {
@@ -733,52 +732,42 @@ export function AssistantMessageDisplay(props: {
return (
<Index each={grouped()}>
{(entryAccessor) => {
const entryType = createMemo(() => entryAccessor().type)
{(entry) => {
const kind = createMemo(() => entry().type)
const parts = createMemo(
() => {
const value = entry()
if (value.type !== "context") return emptyTools
return value.refs
.map((ref) => part().get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
{ equals: same },
)
const item = createMemo(() => {
const value = entry()
if (value.type !== "part") return
return part().get(value.ref.partID)
})
const ready = createMemo(() => {
if (kind() !== "part") return
const value = item()
if (!value) return
return value
})
return (
<Switch>
<Match when={entryType() === "context"}>
{(() => {
const parts = createMemo(
() => {
const entry = entryAccessor()
if (entry.type !== "context") return emptyTools
return entry.refs
.map((ref) => part().get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
{ equals: same },
)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} />
</Show>
)
})()}
</Match>
<Match when={entryType() === "part"}>
{(() => {
const item = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return part().get(entry.ref.partID)
})
return (
<Show when={item()}>
<Part
part={item()!}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
</Show>
)
})()}
</Match>
</Switch>
<>
<Show when={kind() === "context" && parts().length > 0}>
<ContextToolGroup parts={parts()} />
</Show>
<Show when={ready()}>
{(ready) => (
<Part part={ready()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
)}
</Show>
</>
)
}}
</Index>
@@ -845,11 +834,9 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
<Collapsible.Content>
<div data-component="context-tool-group-list">
<Index each={props.parts}>
{(partAccessor) => {
const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
const running = createMemo(
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
)
{(part) => {
const trigger = createMemo(() => contextToolTrigger(part(), i18n))
const running = createMemo(() => part().state.status === "pending" || part().state.status === "running")
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
@@ -887,6 +874,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const data = useData()
const dialog = useDialog()
const i18n = useI18n()
const message = bindMessage(props.message)
const [state, setState] = createStore({
copied: false,
busy: undefined as "fork" | "revert" | undefined,
@@ -909,8 +897,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
const model = createMemo(() => {
const providerID = props.message.model?.providerID
const modelID = props.message.model?.modelID
const providerID = message().model?.providerID
const modelID = message().model?.modelID
if (!providerID || !modelID) return ""
const match = data.store.provider?.all?.find((p) => p.id === providerID)
return match?.models?.[modelID]?.name ?? modelID
@@ -918,13 +906,13 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
const stamp = createMemo(() => {
const created = props.message.time?.created
const created = message().time?.created
if (typeof created !== "number") return ""
return timefmt().format(created)
})
const metaHead = createMemo(() => {
const agent = props.message.agent
const agent = message().agent
const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
@@ -950,8 +938,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
void Promise.resolve()
.then(() =>
act({
sessionID: props.message.sessionID,
messageID: props.message.id,
sessionID: message().sessionID,
messageID: message().id,
}),
)
.finally(() => {
@@ -1310,27 +1298,27 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const i18n = useI18n()
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const part = () => props.part as TextPart
const message = bindMessage(props.message)
const interrupted = createMemo(
() =>
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
() => message().role === "assistant" && (message() as AssistantMessage).error?.name === "MessageAbortedError",
)
const model = createMemo(() => {
if (props.message.role !== "assistant") return ""
const message = props.message as AssistantMessage
const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
return match?.models?.[message.modelID]?.name ?? message.modelID
const current = message()
if (current.role !== "assistant") return ""
const match = data.store.provider?.all?.find((p) => p.id === current.providerID)
return match?.models?.[current.modelID]?.name ?? current.modelID
})
const duration = createMemo(() => {
if (props.message.role !== "assistant") return ""
const message = props.message as AssistantMessage
const completed = message.time.completed
const current = message()
if (current.role !== "assistant") return ""
const completed = current.time.completed
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - message.time.created
? completed - current.time.created
: -1
if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000)
@@ -1344,8 +1332,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
})
const meta = createMemo(() => {
if (props.message.role !== "assistant") return ""
const agent = (props.message as AssistantMessage).agent
const current = message()
if (current.role !== "assistant") return ""
const agent = current.agent
const items = [
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
model(),
@@ -1358,13 +1347,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
const last = (data.store.part?.[message().id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part().id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (message().role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID === null) return false
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
return isLastTextPart()

View File

@@ -752,7 +752,7 @@ export GITLAB_TOKEN=glpat-...
:::note
يجب على مدير GitLab لديك تفعيل ما يلي:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) للمستخدم أو المجموعة أو المثيل
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) للمستخدم أو المجموعة أو المثيل
2. Feature flags (عبر Rails console):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -774,7 +774,7 @@ callback URL `http://127.0.0.1:8080/callback` ونطاقات الصلاحيا
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### التهيئة
@@ -786,7 +786,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -800,7 +804,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -760,7 +760,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Vaš GitLab administrator mora omogućiti sljedeće:
1. [Duo Agent Platforma](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) za korisnika, grupu ili instancu
1. [Duo Agent Platforma](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) za korisnika, grupu ili instancu
2. Zastavice funkcija (preko Rails konzole):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -782,7 +782,7 @@ Zatim izložite ID aplikacije kao varijablu okruženja:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Konfiguracija
@@ -794,7 +794,11 @@ Prilagodite putem `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -808,7 +812,7 @@ Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -749,7 +749,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Din GitLab-administrator skal aktivere følgende:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brugeren, gruppen eller instansen
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brugeren, gruppen eller instansen
2. Funktionsflag (via Rails-konsollen):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -771,7 +771,7 @@ Udsæt derefter applikations-ID som miljøvariabel:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmesiden.
Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmesiden.
##### Konfiguration
@@ -783,7 +783,11 @@ Tilpas gennem `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -797,7 +801,7 @@ For at få adgang til GitLab-værktøjer (merge requests, problemer, pipelines,
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Ihr GitLab-Administrator muss Folgendes aktivieren:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
2. Feature-Flags (über die Rails-Konsole):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -777,7 +777,7 @@ Stellen Sie dann die Anwendung ID als Umgebungsvariable bereit:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)-Homepage.
Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth)-Homepage.
##### Konfiguration
@@ -789,7 +789,11 @@ Anpassen über `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -803,7 +807,7 @@ So greifen Sie auf GitLab-Tools zu (Zusammenführungsanfragen, Probleme, Pipelin
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Su administrador GitLab debe habilitar lo siguiente:
1. [Plataforma de agente Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para el usuario, grupo o instancia
1. [Plataforma de agente Duo](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para el usuario, grupo o instancia
2. Indicadores de funciones (a través de la consola Rails):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -779,7 +779,7 @@ Luego exponga el ID de la aplicación como variable de entorno:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Configuración
@@ -791,7 +791,11 @@ Personalizar a través de `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -805,7 +809,7 @@ Para acceder a herramientas GitLab (solicitudes de fusión, problemas, canalizac
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -763,7 +763,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Votre administrateur GitLab doit activer les éléments suivants :
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
2. Indicateurs de fonctionnalités (via la console Rails) :
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -785,7 +785,7 @@ Exposez ensuite l'ID de l'application en tant que variable d'environnement :
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Configuration
@@ -797,7 +797,11 @@ Personnalisez via `opencode.json` :
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -811,7 +815,7 @@ Pour accéder aux outils GitLab (demandes de fusion, tickets, pipelines, CI/CD,
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -733,7 +733,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Il tuo amministratore GitLab deve abilitare quanto segue:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) per l'utente, gruppo o istanza
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) per l'utente, gruppo o istanza
2. Feature flags (via Rails console):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -755,7 +755,7 @@ Poi esponi l'ID applicazione come variabile d'ambiente:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Configurazione
@@ -767,7 +767,11 @@ Personalizza tramite `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -781,7 +785,7 @@ Per accedere agli strumenti GitLab (merge requests, issues, pipelines, CI/CD, ec
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -797,7 +797,7 @@ export GITLAB_TOKEN=glpat-...
:::note
GitLab 管理者は以下を有効にする必要があります。
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
2. 機能フラグ (Rails コンソール経由):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -819,7 +819,7 @@ GitLab 管理者は以下を有効にする必要があります。
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ホームページ。
詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) ホームページ。
##### 設定
@@ -831,7 +831,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -845,7 +849,7 @@ GitLab ツール (マージリクエスト、問題、パイプライン、CI/CD
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -758,7 +758,7 @@ export GITLAB_TOKEN=glpat-...
:::note
GitLab 관리자는 다음을 활성화해야 합니다:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (사용자, 그룹 또는 인스턴스)
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (사용자, 그룹 또는 인스턴스)
2. 기능 플래그 (Rails 콘솔을 통해):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -779,7 +779,7 @@ GitLab 관리자는 다음을 활성화해야 합니다:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
#### 구성
@@ -791,7 +791,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -805,7 +809,7 @@ GitLab 도구(병합 요청, 이슈, 파이프라인, CI/CD 등)에 액세스하
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
:::note
GitLab-administratoren din må aktivere følgende:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brukeren, gruppen eller forekomsten
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brukeren, gruppen eller forekomsten
2. Funksjonsflagg (via Rails-konsollen):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -779,7 +779,7 @@ Utsett deretter applikasjonen ID som miljøvariabel:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmeside.
Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmeside.
##### Konfigurasjon
@@ -791,7 +791,11 @@ Tilpass gjennom `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -805,7 +809,7 @@ For å få tilgang til GitLab-verktøy (sammenslåingsforespørsler, problemer,
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Twój administrator GitLab musi włączyć następujące opcje:
1. [Platforma Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) dla użytkownika, grupy lub instancji
1. [Platforma Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) dla użytkownika, grupy lub instancji
2. Feature flags (via Rails console):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -777,7 +777,7 @@ Następnie ustaw ID aplikacji jako zmienną środowiskową:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Konfiguracja
@@ -789,7 +789,11 @@ Customize through `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -803,7 +807,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -544,47 +544,6 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
---
### Cloudflare Workers AI
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
2. Set your Account ID as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
```
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
```txt
/connect
```
4. Enter your Cloudflare API token.
```txt
┌ API key
└ enter
```
Or set it as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_API_KEY=your-api-token
```
5. Run the `/models` command to select a model.
```txt
/models
```
---
### Cortecs
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.
@@ -722,20 +681,7 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
### GitLab Duo
:::caution[Experimental]
GitLab Duo support in OpenCode is experimental. Features, configuration, and
behavior may change in future releases.
:::
OpenCode integrates with the [GitLab Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/),
providing AI-powered agentic chat with native tool calling capabilities.
:::note[License requirements]
GitLab Duo Agent Platform requires a **Premium** or **Ultimate** GitLab
subscription. It is available on GitLab.com and GitLab Self-Managed.
See [GitLab Duo Agent Platform prerequisites](https://docs.gitlab.com/user/duo_agent_platform/#prerequisites)
for full requirements.
:::
GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy.
1. Run the `/connect` command and select GitLab.
@@ -820,15 +766,13 @@ export GITLAB_TOKEN=glpat-...
```
:::note
Your GitLab administrator must:
Your GitLab administrator must enable the following:
1. [Turn on GitLab Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-on-or-off)
for the user, group, or instance
2. [Turn on the Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-agent-platform-on-or-off)
(GitLab 18.8+) or [enable beta and experimental features](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-on-beta-and-experimental-features)
(GitLab 18.7 and earlier)
3. For Self-Managed, [configure your instance](https://docs.gitlab.com/administration/gitlab_duo/configure/gitlab_self_managed/)
:::
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance
2. Feature flags (via Rails console):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
:::
##### OAuth for Self-Hosted instances
@@ -846,7 +790,7 @@ Then expose application ID as environment variable:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage.
More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage.
##### Configuration
@@ -858,27 +802,17 @@ Customize through `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
}
```
##### GitLab Duo Agent Platform (DAP) Workflow Models
DAP workflow models provide an alternative execution path that routes tool calls
through GitLab's Duo Workflow Service (DWS) instead of the standard agentic chat.
When a `duo-workflow-*` model is selected, OpenCode will:
1. Discover available models from your GitLab namespace
2. Present a selection picker if multiple models are available
3. Cache the selected model to disk for fast subsequent startups
4. Route tool execution requests through OpenCode's permission-gated tool system
Available DAP workflow models follow the `duo-workflow-*` naming convention and
are dynamically discovered from your GitLab instance.
##### GitLab API Tools (Optional, but highly recommended)
To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
@@ -886,7 +820,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -759,7 +759,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Seu administrador do GitLab deve habilitar o seguinte:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para o usuário, grupo ou instância
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para o usuário, grupo ou instância
2. Flags de recurso (via console Rails):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -781,7 +781,7 @@ Em seguida, exponha o ID do aplicativo como variável de ambiente:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Configuração
@@ -793,7 +793,11 @@ Personalize através do `opencode.json`:
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -807,7 +811,7 @@ Para acessar ferramentas do GitLab (merge requests, issues, pipelines, CI/CD, et
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
:::note
Ваш администратор GitLab должен включить следующее:
1. [Платформа Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) для пользователя, группы или экземпляра
1. [Платформа Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) для пользователя, группы или экземпляра
2. Флаги функций (через консоль Rails):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -777,7 +777,7 @@ URL обратного вызова `http://127.0.0.1:8080/callback` и след
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
##### Конфигурация
@@ -789,7 +789,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -803,7 +807,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -756,7 +756,7 @@ export GITLAB_TOKEN=glpat-...
:::note
ผู้ดูแลระบบ GitLab ของคุณต้องเปิดใช้งานสิ่งต่อไปนี้:
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) สำหรับผู้ใช้ กลุ่ม หรืออินสแตนซ์
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) สำหรับผู้ใช้ กลุ่ม หรืออินสแตนซ์
2. แฟล็กคุณลักษณะ (ผ่านคอนโซล Rails):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -778,7 +778,7 @@ export GITLAB_TOKEN=glpat-...
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
เอกสารประกอบเพิ่มเติมเกี่ยวกับหน้าแรกของ [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)
เอกสารประกอบเพิ่มเติมเกี่ยวกับหน้าแรกของ [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth)
##### การกำหนดค่า
@@ -790,7 +790,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -804,7 +808,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
:::note
GitLab yöneticiniz aşağıdakileri etkinleştirmelidir:
1. Kullanıcı, grup veya örnek için [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
1. Kullanıcı, grup veya örnek için [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
2. Feature flags (via Rails console):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -779,7 +779,7 @@ Then expose application ID as environment variable:
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ana sayfasında.
Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) ana sayfasında.
##### Yapılandırma
@@ -791,7 +791,11 @@ Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-g
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -805,7 +809,7 @@ GitLab araçlarına (birleştirme istekleri, sorunlar, işlem hatları, CI/CD vb
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -725,7 +725,7 @@ export GITLAB_TOKEN=glpat-...
:::note
你的 GitLab 管理员必须启用以下功能:
1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
2. 功能标志(通过 Rails 控制台):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -745,7 +745,7 @@ export GITLAB_TOKEN=glpat-...
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 主页。
更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 主页。
##### 配置
@@ -757,7 +757,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -771,7 +775,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -746,7 +746,7 @@ export GITLAB_TOKEN=glpat-...
:::note
您的 GitLab 管理員必須啟用以下功能:
1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
2. 功能旗標(透過 Rails 控制台):
- `agent_platform_claude_code`
- `third_party_agents_enabled`
@@ -766,7 +766,7 @@ export GITLAB_TOKEN=glpat-...
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```
更多文件請參閱 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 首頁。
更多文件請參閱 [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 首頁。
##### 設定
@@ -778,7 +778,11 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
"provider": {
"gitlab": {
"options": {
"instanceUrl": "https://gitlab.com"
"instanceUrl": "https://gitlab.com",
"featureFlags": {
"duo_agent_platform_agentic_chat": true,
"duo_agent_platform": true
}
}
}
}
@@ -792,7 +796,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gitlab-plugin"]
"plugin": ["@gitlab/opencode-gitlab-plugin"]
}
```

View File

@@ -1,58 +0,0 @@
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/dev.cjs b/dist/dev.cjs
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
--- a/dist/dev.cjs
+++ b/dist/dev.cjs
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/dev.js b/dist/dev.js
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
--- a/dist/dev.js
+++ b/dist/dev.js
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/solid.cjs b/dist/solid.cjs
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
--- a/dist/solid.cjs
+++ b/dist/solid.cjs
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/solid.js b/dist/solid.js
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
--- a/dist/solid.js
+++ b/dist/solid.js
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;