From 05d3e65f767360a38a508ad198df15ca3f8c2bbe Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Fri, 20 Mar 2026 18:55:22 +0100 Subject: [PATCH 001/108] feat: enable GitLab Agent Platform with workflow model discovery (#18014) --- bun.lock | 26 ++-- packages/opencode/package.json | 4 +- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/provider/provider.ts | 128 ++++++++++++++++-- .../opencode/src/server/routes/provider.ts | 3 + packages/opencode/src/session/llm.ts | 29 ++++ .../opencode/test/provider/gitlab-duo.test.ts | 123 ++++++++++++++++- .../test/tool/fixtures/models-api.json | 2 +- .../web/src/content/docs/ar/providers.mdx | 12 +- .../web/src/content/docs/bs/providers.mdx | 12 +- .../web/src/content/docs/da/providers.mdx | 12 +- .../web/src/content/docs/de/providers.mdx | 12 +- .../web/src/content/docs/es/providers.mdx | 12 +- .../web/src/content/docs/fr/providers.mdx | 12 +- .../web/src/content/docs/it/providers.mdx | 12 +- .../web/src/content/docs/ja/providers.mdx | 12 +- .../web/src/content/docs/ko/providers.mdx | 12 +- .../web/src/content/docs/nb/providers.mdx | 12 +- .../web/src/content/docs/pl/providers.mdx | 12 +- packages/web/src/content/docs/providers.mdx | 53 ++++++-- .../web/src/content/docs/pt-br/providers.mdx | 12 +- .../web/src/content/docs/ru/providers.mdx | 12 +- .../web/src/content/docs/th/providers.mdx | 12 +- .../web/src/content/docs/tr/providers.mdx | 12 +- .../web/src/content/docs/zh-cn/providers.mdx | 12 +- .../web/src/content/docs/zh-tw/providers.mdx | 12 +- 26 files changed, 393 insertions(+), 181 deletions(-) diff --git a/bun.lock b/bun.lock index ee8746c42f..58cfe892f3 100644 --- a/bun.lock +++ b/bun.lock @@ -325,8 +325,6 @@ "@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", @@ -358,6 +356,7 @@ "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", @@ -368,6 +367,7 @@ "mime-types": "3.0.2", "minimatch": "10.0.3", "open": "10.1.2", + "opencode-gitlab-auth": "2.0.0", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", "remeda": "catalog:", @@ -1110,10 +1110,6 @@ "@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=="], @@ -3032,6 +3028,8 @@ "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=="], @@ -3784,6 +3782,8 @@ "opencode": ["opencode@workspace:packages/opencode"], + "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="], + "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], @@ -4246,7 +4246,7 @@ "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], - "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "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=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -5060,10 +5060,6 @@ "@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=="], @@ -5460,6 +5456,10 @@ "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,6 +5536,8 @@ "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=="], @@ -6286,6 +6288,8 @@ "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=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3e73a7021d..f888cc829b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -89,9 +89,9 @@ "@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", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 755ce2c211..9e294f4f53 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9c9c8e8343..6ab45d028b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -40,7 +40,12 @@ 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 } from "@gitlab/gitlab-ai-provider" +import { + createGitLab, + VERSION as GITLAB_PROVIDER_VERSION, + isWorkflowModel, + discoverWorkflowModels, +} from "gitlab-ai-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" @@ -124,18 +129,20 @@ export namespace Provider { "@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/perplexity": createPerplexity, "@ai-sdk/vercel": createVercel, - "@gitlab/gitlab-ai-provider": createGitLab, + "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) => Promise type CustomVarsLoader = (options: Record) => Record + type CustomDiscoverModels = () => Promise> type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: CustomModelLoader vars?: CustomVarsLoader options?: Record + discoverModels?: CustomDiscoverModels }> function useLanguageModel(sdk: any) { @@ -533,28 +540,105 @@ 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: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }, - async getModel(sdk: ReturnType, modelID: string) { + async getModel(sdk: ReturnType, modelID: string, options?: Record) { + 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 + } return sdk.agenticChat(modelID, { aiGatewayHeaders, - featureFlags: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }) }, + async discoverModels(): Promise> { + if (!apiKey) { + log.info("gitlab model discovery skipped: no apiKey") + return {} + } + + try { + const token = apiKey + const getHeaders = (): Record => + 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 = {} + 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) => { @@ -853,6 +937,9 @@ export namespace Provider { const varsLoaders: { [providerID: string]: CustomVarsLoader } = {} + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} const sdk = new Map() log.info("init") @@ -1009,6 +1096,7 @@ 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 = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) @@ -1070,6 +1158,18 @@ 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, @@ -1250,7 +1350,7 @@ export namespace Provider { try { const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options) + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options }) : sdk.languageModel(model.api.id) s.models.set(key, language) return language diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 3ac3e7c64a..64fe34f450 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -9,6 +9,9 @@ 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() diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 48a36741c9..748fd3eb21 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -12,6 +12,7 @@ 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" @@ -184,6 +185,34 @@ 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", { diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index f30e3297de..15c797022d 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -1,12 +1,13 @@ -import { test, expect } from "bun:test" +import { test, expect, describe } from "bun:test" import path from "path" -import { ProviderID } from "../../src/provider/schema" +import { ProviderID, ModelID } 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({ @@ -287,3 +288,121 @@ 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") + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 391e783699..715224cd3a 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -32933,7 +32933,7 @@ "gitlab": { "id": "gitlab", "env": ["GITLAB_TOKEN"], - "npm": "@gitlab/gitlab-ai-provider", + "npm": "gitlab-ai-provider", "name": "GitLab Duo", "doc": "https://docs.gitlab.com/user/duo_agent_platform/", "models": { diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index f5dd70125f..951d6701cb 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -752,7 +752,7 @@ export GITLAB_TOKEN=glpat-... :::note يجب على مدير GitLab لديك تفعيل ما يلي: -1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) للمستخدم أو المجموعة أو المثيل +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### التهيئة @@ -786,11 +786,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -804,7 +800,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 6bdcf45778..1aae0a93a9 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -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/gitlab_duo/turn_on_off/) za korisnika, grupu ili instancu +1. [Duo Agent Platforma](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Konfiguracija @@ -794,11 +794,7 @@ Prilagodite putem `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -812,7 +808,7 @@ Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index c5cfe23fa8..bcd82c8454 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -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/gitlab_duo/turn_on_off/) for brugeren, gruppen eller instansen +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) hjemmesiden. +Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmesiden. ##### Konfiguration @@ -783,11 +783,7 @@ Tilpas gennem `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -801,7 +797,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": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index fa447594d6..c012a78fc2 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-... :::note Ihr GitLab-Administrator muss Folgendes aktivieren: -1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth)-Homepage. +Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)-Homepage. ##### Konfiguration @@ -789,11 +789,7 @@ Anpassen über `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ So greifen Sie auf GitLab-Tools zu (Zusammenführungsanfragen, Probleme, Pipelin ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index 2ee033f00d..b812971409 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -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/gitlab_duo/turn_on_off/) para el usuario, grupo o instancia +1. [Plataforma de agente Duo](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Configuración @@ -791,11 +791,7 @@ Personalizar a través de `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ Para acceder a herramientas GitLab (solicitudes de fusión, problemas, canalizac ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 36e1ed2d2b..d7b0657971 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -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/gitlab_duo/turn_on_off/) pour l'utilisateur, le groupe ou l'instance +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Configuration @@ -797,11 +797,7 @@ Personnalisez via `opencode.json` : "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -815,7 +811,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": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index c0c5489d08..58bb28407f 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -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/gitlab_duo/turn_on_off/) per l'utente, gruppo o istanza +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Configurazione @@ -767,11 +767,7 @@ Personalizza tramite `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -785,7 +781,7 @@ Per accedere agli strumenti GitLab (merge requests, issues, pipelines, CI/CD, ec ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 388dc8e41d..f7e88d8c5d 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -797,7 +797,7 @@ export GITLAB_TOKEN=glpat-... :::note GitLab 管理者は以下を有効にする必要があります。 -1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (ユーザー、グループ、またはインスタンス用) +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) ホームページ。 +詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ホームページ。 ##### 設定 @@ -831,11 +831,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -849,7 +845,7 @@ GitLab ツール (マージリクエスト、問題、パイプライン、CI/CD ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index c543c719dd..ccbbc48386 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -758,7 +758,7 @@ export GITLAB_TOKEN=glpat-... :::note GitLab 관리자는 다음을 활성화해야 합니다: -1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (사용자, 그룹 또는 인스턴스) +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다. +[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다. #### 구성 @@ -791,11 +791,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ GitLab 도구(병합 요청, 이슈, 파이프라인, CI/CD 등)에 액세스하 ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 682f923f8c..9e025a96f8 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -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/gitlab_duo/turn_on_off/) for brukeren, gruppen eller forekomsten +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) hjemmeside. +Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmeside. ##### Konfigurasjon @@ -791,11 +791,7 @@ Tilpass gjennom `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ For å få tilgang til GitLab-verktøy (sammenslåingsforespørsler, problemer, ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index fa50901103..aeb6272331 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -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/gitlab_duo/turn_on_off/) dla użytkownika, grupy lub instancji +1. [Platforma Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Konfiguracja @@ -789,11 +789,7 @@ Customize through `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index da55875f8e..b14c8ab10a 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -722,7 +722,20 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire ### GitLab Duo -GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy. +:::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. +::: 1. Run the `/connect` command and select GitLab. @@ -807,13 +820,15 @@ export GITLAB_TOKEN=glpat-... ``` :::note -Your GitLab administrator must enable the following: +Your GitLab administrator must: -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` - ::: +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/) + ::: ##### OAuth for Self-Hosted instances @@ -831,7 +846,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/@gitlab/opencode-gitlab-auth) homepage. +More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage. ##### Configuration @@ -843,17 +858,27 @@ Customize through `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } } ``` +##### 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.): @@ -861,7 +886,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 2ef2ebdc00..4424a55fc0 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -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/gitlab_duo/turn_on_off/) para o usuário, grupo ou instância +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Configuração @@ -793,11 +793,7 @@ Personalize através do `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -811,7 +807,7 @@ Para acessar ferramentas do GitLab (merge requests, issues, pipelines, CI/CD, et ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index c36dfd9f78..1cb3873c32 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-... :::note Ваш администратор GitLab должен включить следующее: -1. [Платформа Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) для пользователя, группы или экземпляра +1. [Платформа Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth). +Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). ##### Конфигурация @@ -789,11 +789,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 122ade4277..73489b96ad 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -756,7 +756,7 @@ export GITLAB_TOKEN=glpat-... :::note ผู้ดูแลระบบ GitLab ของคุณต้องเปิดใช้งานสิ่งต่อไปนี้: -1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) สำหรับผู้ใช้ กลุ่ม หรืออินสแตนซ์ +1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) +เอกสารประกอบเพิ่มเติมเกี่ยวกับหน้าแรกของ [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ##### การกำหนดค่า @@ -790,11 +790,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -808,7 +804,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 1ddc65131c..871f9e128c 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -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/gitlab_duo/turn_on_off/) +1. Kullanıcı, grup veya örnek için [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) ana sayfasında. +Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ana sayfasında. ##### Yapılandırma @@ -791,11 +791,7 @@ Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/op "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,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": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 571b730242..25b7d03a4d 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -725,7 +725,7 @@ export GITLAB_TOKEN=glpat-... :::note 你的 GitLab 管理员必须启用以下功能: -1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) +1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) 主页。 +更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 主页。 ##### 配置 @@ -757,11 +757,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -775,7 +771,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index b673b1ade5..1024444d18 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -746,7 +746,7 @@ export GITLAB_TOKEN=glpat-... :::note 您的 GitLab 管理員必須啟用以下功能: -1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) +1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/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/@gitlab/opencode-gitlab-auth) 首頁。 +更多文件請參閱 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 首頁。 ##### 設定 @@ -778,11 +778,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -796,7 +792,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` From ce845a0b1b169c2d8a33a0d2db03c6cd40d7b29f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 20 Mar 2026 18:16:17 +0000 Subject: [PATCH 002/108] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 9ee3aeab2d..8f48d1aaba 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=", - "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=", - "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=", - "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA=" + "x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=", + "aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=", + "aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=", + "x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA=" } } From d70099b0596b60450ca3c0d45b01816eca25fb54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 14:37:12 -0400 Subject: [PATCH 003/108] fix: apply Layer.fresh at instance service definition site (#18418) --- packages/opencode/src/effect/instances.ts | 38 +- packages/opencode/src/file/index.ts | 689 +----------------- packages/opencode/src/file/service.ts | 674 +++++++++++++++++ packages/opencode/src/file/time-service.ts | 93 +++ packages/opencode/src/file/time.ts | 100 +-- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/index.ts | 155 +--- packages/opencode/src/format/service.ts | 152 ++++ packages/opencode/src/permission/index.ts | 300 +------- packages/opencode/src/permission/service.ts | 282 +++++++ packages/opencode/src/project/vcs.ts | 2 +- .../opencode/src/provider/auth-service.ts | 215 ++++++ packages/opencode/src/provider/auth.ts | 226 +----- packages/opencode/src/question/index.ts | 194 +---- packages/opencode/src/question/service.ts | 172 +++++ packages/opencode/src/skill/service.ts | 238 ++++++ packages/opencode/src/skill/skill.ts | 250 +------ packages/opencode/src/snapshot/index.ts | 341 +-------- packages/opencode/src/snapshot/service.ts | 320 ++++++++ packages/opencode/test/effect/runtime.test.ts | 128 ++++ packages/opencode/test/fixture/instance.ts | 2 +- 21 files changed, 2434 insertions(+), 2139 deletions(-) create mode 100644 packages/opencode/src/file/service.ts create mode 100644 packages/opencode/src/file/time-service.ts create mode 100644 packages/opencode/src/format/service.ts create mode 100644 packages/opencode/src/permission/service.ts create mode 100644 packages/opencode/src/provider/auth-service.ts create mode 100644 packages/opencode/src/question/service.ts create mode 100644 packages/opencode/src/skill/service.ts create mode 100644 packages/opencode/src/snapshot/service.ts create mode 100644 packages/opencode/test/effect/runtime.test.ts diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df..6fcfddb24f 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,15 +1,15 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { File } from "@/file" -import { FileTime } from "@/file/time" +import { File } from "@/file/service" +import { FileTime } from "@/file/time-service" import { FileWatcher } from "@/file/watcher" -import { Format } from "@/format" -import { PermissionNext } from "@/permission" +import { Format } from "@/format/service" +import { Permission } from "@/permission/service" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" -import { ProviderAuth } from "@/provider/auth" -import { Question } from "@/question" -import { Skill } from "@/skill/skill" -import { Snapshot } from "@/snapshot" +import { ProviderAuth } from "@/provider/auth-service" +import { Question } from "@/question/service" +import { Skill } from "@/skill/service" +import { Snapshot } from "@/snapshot/service" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -17,7 +17,7 @@ export { InstanceContext } from "./instance-context" export type InstanceServices = | Question.Service - | PermissionNext.Service + | Permission.Service | ProviderAuth.Service | FileWatcher.Service | Vcs.Service @@ -36,16 +36,16 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(Question.layer), - Layer.fresh(PermissionNext.layer), - Layer.fresh(ProviderAuth.defaultLayer), - Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.layer), - Layer.fresh(FileTime.layer).pipe(Layer.orDie), - Layer.fresh(Format.layer), - Layer.fresh(File.layer), - Layer.fresh(Skill.defaultLayer), - Layer.fresh(Snapshot.defaultLayer), + Question.layer, + Permission.layer, + ProviderAuth.defaultLayer, + FileWatcher.layer, + Vcs.layer, + FileTime.layer, + Format.layer, + File.layer, + Skill.defaultLayer, + Snapshot.defaultLayer, ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6e9b917271..35a5b5e204 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,695 +1,40 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { git } from "@/util/git" -import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" -import { formatPatch, structuredPatch } from "diff" -import fs from "fs" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import path from "path" -import z from "zod" -import { Global } from "../global" -import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" -import { Protected } from "./protected" -import { Ripgrep } from "./ripgrep" +import { File as S } from "./service" 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 const Info = S.Info + export type Info = S.Info - export type Info = z.infer + export const Node = S.Node + export type Node = S.Node - 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 + export const Content = S.Content + export type Content = S.Content - 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 + export const Event = S.Event - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), - } + export type Interface = S.Interface + + export const Service = S.Service + export const layer = S.layer export function init() { - return runPromiseInstance(Service.use((svc) => svc.init())) + return runPromiseInstance(S.Service.use((svc) => svc.init())) } export async function status() { - return runPromiseInstance(Service.use((svc) => svc.status())) + return runPromiseInstance(S.Service.use((svc) => svc.status())) } export async function read(file: string): Promise { - return runPromiseInstance(Service.use((svc) => svc.read(file))) + return runPromiseInstance(S.Service.use((svc) => svc.read(file))) } export async function list(dir?: string) { - return runPromiseInstance(Service.use((svc) => svc.list(dir))) + return runPromiseInstance(S.Service.use((svc) => svc.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(Service.use((svc) => svc.search(input))) + return runPromiseInstance(S.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 = { - 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 - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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() - 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() - 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 | 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 => { - 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 }) - }), - ) } diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts new file mode 100644 index 0000000000..d4f6b347f8 --- /dev/null +++ b/packages/opencode/src/file/service.ts @@ -0,0 +1,674 @@ +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 + + 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 + + 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 + + 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 = { + 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 + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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() + 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() + 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 | 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 => { + 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) +} diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts new file mode 100644 index 0000000000..a0fa8bfabf --- /dev/null +++ b/packages/opencode/src/file/time-service.ts @@ -0,0 +1,93 @@ +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: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map() + reads.set(sessionID, next) + return next + } + + export interface Interface { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const reads = new Map>() + const locks = new Map() + + 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* (filepath: string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) + }) + + return Service.of({ read, get, assert, withLock }) + }), + ).pipe(Layer.orDie, Layer.fresh) +} diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 3d94bc1222..b6d572fe8b 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,110 +1,28 @@ -import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" +import { FileTime as S } from "./time-service" export namespace FileTime { - const log = Log.create({ service: "file.time" }) + export type Stamp = S.Stamp - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly ctime: number | undefined - readonly size: number | undefined - } + export type Interface = S.Interface - 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: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() - - 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* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ) + export const Service = S.Service + export const layer = S.layer export function read(sessionID: SessionID, file: string) { - return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) + return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file))) } export function get(sessionID: SessionID, file: string) { - return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) + return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file))) } export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) + return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) + return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ab3c6d33..7e5f5f7be3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -137,5 +137,5 @@ export namespace FileWatcher { return Effect.succeed(Service.of({})) }), ), - ) + ).pipe(Layer.orDie, Layer.fresh) } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 6da8caa08c..e4381c69b2 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,157 +1,16 @@ -import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { InstanceContext } from "@/effect/instance-context" -import path from "path" -import { mergeDeep } from "remeda" -import z from "zod" -import { Bus } from "../bus" -import { Config } from "../config/config" -import { File } from "../file" -import { Instance } from "../project/instance" -import { Process } from "../util/process" -import { Log } from "../util/log" -import * as Formatter from "./formatter" +import { Format as S } from "./service" export namespace Format { - const log = Log.create({ service: "format" }) + export const Status = S.Status + export type Status = S.Status - export const Status = z - .object({ - name: z.string(), - extensions: z.string().array(), - enabled: z.boolean(), - }) - .meta({ - ref: "FormatterStatus", - }) - export type Status = z.infer + export type Interface = S.Interface - export interface Interface { - readonly status: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - - const enabled: Record = {} - const formatters: Record = {} - - 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 const Service = S.Service + export const layer = S.layer export async function status() { - return runPromiseInstance(Service.use((s) => s.status())) + return runPromiseInstance(S.Service.use((s) => s.status())) } } diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts new file mode 100644 index 0000000000..64fff79497 --- /dev/null +++ b/packages/opencode/src/format/service.ts @@ -0,0 +1,152 @@ +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 + + export interface Interface { + readonly status: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Format") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const enabled: Record = {} + const formatters: Record = {} + + 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) +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 321c5c374e..01ac768971 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,292 +1,52 @@ 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 { evaluate as evalRule } from "./evaluate" -import { PermissionID } from "./schema" +import { Permission as S } from "./service" export namespace PermissionNext { - const log = Log.create({ service: "permission" }) + export const Action = S.Action + export type Action = S.Action - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer + export const Rule = S.Rule + export type Rule = S.Rule - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + export const Ruleset = S.Ruleset + export type Ruleset = S.Ruleset - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Request = S.Request + export type Request = S.Request - 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 + export const Reply = S.Reply + export type Reply = S.Reply - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const Approval = S.Approval + export type Approval = z.infer - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) + export const Event = S.Event - 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 RejectedError = S.RejectedError + export const CorrectedError = S.CorrectedError + export const DeniedError = S.DeniedError + export type Error = S.Error - export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } + export const AskInput = S.AskInput + export const ReplyInput = S.ReplyInput - export class CorrectedError extends Schema.TaggedErrorClass()("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 type Interface = S.Interface - export class DeniedError extends Schema.TaggedErrorClass()("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 Service = S.Service + export const layer = S.layer - export type Error = DeniedError | RejectedError | CorrectedError + export const evaluate = S.evaluate + export const fromConfig = S.fromConfig + export const merge = S.merge + export const disabled = S.disabled - export const AskInput = Request.partial({ id: true }).extend({ - ruleset: Ruleset, - }) + export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input)))) - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }) - - export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - 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()("@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() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - 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() - 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) { - 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 const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input)))) export async function list() { - return runPromiseInstance(Service.use((svc) => svc.list())) - } - - const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] - - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - 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 + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts new file mode 100644 index 0000000000..08475520b2 --- /dev/null +++ b/packages/opencode/src/permission/service.ts @@ -0,0 +1,282 @@ +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 + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer + + 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 + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + 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()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } + } + + export class CorrectedError extends Schema.TaggedErrorClass()("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()("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) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + 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()("@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() + const approved: Ruleset = row?.data ?? [] + + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + 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() + 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) { + 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 { + const result = new Set() + 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 + } +} diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9e85571c49..9a9e42ecf8 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -79,5 +79,5 @@ export namespace Vcs { }), }) }), - ) + ).pipe(Layer.fresh) } diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts new file mode 100644 index 0000000000..5045e1edd2 --- /dev/null +++ b/packages/opencode/src/provider/auth-service.ts @@ -0,0 +1,215 @@ +import type { AuthOuathResult } 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 { + 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 + + 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 + + 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 + | InstanceType + | InstanceType + | InstanceType + + export interface Interface { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Auth.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() + + 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 + }) { + 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 }) + }), + ).pipe(Layer.fresh) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index fe6409776d..ff3433797a 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,222 +1,30 @@ -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 = 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 + export const Method = S.Method + export type Method = S.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 + export const Authorization = S.Authorization + export type Authorization = S.Authorization - export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) + 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 OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ providerID: ProviderID.zod }), - ) + export type Interface = S.Interface - 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 - | InstanceType - | InstanceType - | InstanceType - - export interface Interface { - readonly methods: () => Effect.Effect> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Auth.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() - - 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 - }) { - 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.Auth.layer)) + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer export async function methods() { - return runPromiseInstance(Service.use((svc) => svc.methods())) + return runPromiseInstance(S.Service.use((svc) => svc.methods())) } export const authorize = fn( @@ -225,7 +33,7 @@ export namespace ProviderAuth { method: z.number(), inputs: z.record(z.string(), z.string()).optional(), }), - async (input): Promise => runPromiseInstance(Service.use((svc) => svc.authorize(input))), + async (input): Promise => runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) export const callback = fn( @@ -234,6 +42,6 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))), + async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), ) } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 551c513999..de00951908 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,193 +1,49 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" - -const log = Log.create({ service: "question" }) +import type { MessageID, SessionID } from "@/session/schema" +import type { QuestionID } from "./schema" +import { Question as S } from "./service" export namespace Question { - // Schemas + export const Option = S.Option + export type Option = S.Option - 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 + export const Info = S.Info + export type Info = S.Info - 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 + export const Request = S.Request + export type Request = S.Request - 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 + export const Answer = S.Answer + export type Answer = S.Answer - export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) - export type Answer = z.infer + export const Reply = S.Reply + export type Reply = S.Reply - 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 + export const Event = S.Event + export const RejectedError = S.RejectedError - 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 type Interface = S.Interface - export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - // Service - - export interface Interface { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Question") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const pending = new Map() - - 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() - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - pending.set(id, { info, deferred }) - Bus.publish(Event.Asked, info) - - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { - const existing = pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - pending.delete(input.requestID) - log.info("replied", { requestID: input.requestID, answers: input.answers }) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - yield* Deferred.succeed(existing.deferred, input.answers) - }) - - const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { - const existing = pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - pending.delete(requestID) - log.info("rejected", { requestID }) - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - yield* Deferred.fail(existing.deferred, new RejectedError()) - }) - - const list = Effect.fn("Question.list")(function* () { - return Array.from(pending.values(), (x) => x.info) - }) - - return Service.of({ ask, reply, reject, list }) - }), - ) + export const Service = S.Service + export const layer = S.layer export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromiseInstance(Service.use((svc) => svc.ask(input))) + return runPromiseInstance(S.Service.use((s) => s.ask(input))) } - export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - return runPromiseInstance(Service.use((svc) => svc.reply(input))) + export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { + return runPromiseInstance(S.Service.use((s) => s.reply(input))) } - export async function reject(requestID: QuestionID): Promise { - return runPromiseInstance(Service.use((svc) => svc.reject(requestID))) + export async function reject(requestID: QuestionID) { + return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) } - export async function list(): Promise { - return runPromiseInstance(Service.use((svc) => svc.list())) + export async function list() { + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts new file mode 100644 index 0000000000..a23703e97a --- /dev/null +++ b/packages/opencode/src/question/service.ts @@ -0,0 +1,172 @@ +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { SessionID, MessageID } from "@/session/schema" +import { Log } from "@/util/log" +import z from "zod" +import { QuestionID } from "./schema" + +const log = Log.create({ service: "question" }) + +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 + + 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 + + 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 + + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer + + 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 + + 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()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + // Service + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const pending = new Map() + + 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() + 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) +} diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts new file mode 100644 index 0000000000..434a51bad9 --- /dev/null +++ b/packages/opencode/src/skill/service.ts @@ -0,0 +1,238 @@ +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 + + export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), + ) + + export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), + ) + + type State = { + skills: Record + dirs: Set + task?: Promise + } + + type Cache = State & { + ensure: () => Promise + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } + + 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(), + } + + 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()("@opencode/Skill") {} + + export const layer: Layer.Layer = 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 = 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 [ + "", + ...list.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 5339691a01..ed3e0a4b75 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,255 +1,35 @@ -import os from "os" -import path from "path" -import { pathToFileURL } from "url" -import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" -import { NamedError } from "@opencode-ai/util/error" -import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { Flag } from "@/flag/flag" -import { Global } from "@/global" -import { PermissionNext } from "@/permission" -import { Filesystem } from "@/util/filesystem" -import { Config } from "../config/config" -import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" -import { Log } from "../util/log" -import { Discovery } from "./discovery" +import type { Agent } from "@/agent/agent" +import { Skill as S } from "./service" 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 = S.Info + export type Info = S.Info - export const Info = z.object({ - name: z.string(), - description: z.string(), - location: z.string(), - content: z.string(), - }) - export type Info = z.infer + export const InvalidError = S.InvalidError + export const NameMismatchError = S.NameMismatchError - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) + export type Interface = S.Interface - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer - type State = { - skills: Record - dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - 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(), - } - - 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()("@opencode/Skill") {} - - export const layer: Layer.Layer = 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 = layer.pipe( - Layer.provide(Discovery.defaultLayer), - ) + export const fmt = S.fmt export async function get(name: string) { - return runPromiseInstance(Service.use((skill) => skill.get(name))) + return runPromiseInstance(S.Service.use((skill) => skill.get(name))) } export async function all() { - return runPromiseInstance(Service.use((skill) => skill.all())) + return runPromiseInstance(S.Service.use((skill) => skill.all())) } export async function dirs() { - return runPromiseInstance(Service.use((skill) => skill.dirs())) + return runPromiseInstance(S.Service.use((skill) => skill.dirs())) } export async function available(agent?: Agent.Info) { - return runPromiseInstance(Service.use((skill) => skill.available(agent))) - } - - export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." - - if (opts.verbose) { - return [ - "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + return runPromiseInstance(S.Service.use((skill) => skill.available(agent))) } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 887bce3341..4f845ca2de 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,349 +1,44 @@ -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 { AppFileSystem } from "@/filesystem" -import { Config } from "../config/config" -import { Global } from "../global" -import { Log } from "../util/log" +import { Snapshot as S } from "./service" export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer + export const Patch = S.Patch + export type Patch = S.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 + 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 async function cleanup() { - return runPromiseInstance(Service.use((svc) => svc.cleanup())) + return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) } export async function track() { - return runPromiseInstance(Service.use((svc) => svc.track())) + return runPromiseInstance(S.Service.use((svc) => svc.track())) } export async function patch(hash: string) { - return runPromiseInstance(Service.use((svc) => svc.patch(hash))) + return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) } export async function restore(snapshot: string) { - return runPromiseInstance(Service.use((svc) => svc.restore(snapshot))) + return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot))) } export async function revert(patches: Patch[]) { - return runPromiseInstance(Service.use((svc) => svc.revert(patches))) + return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) } export async function diff(hash: string) { - return runPromiseInstance(Service.use((svc) => svc.diff(hash))) + return runPromiseInstance(S.Service.use((svc) => svc.diff(hash))) } export async function diffFull(from: string, to: string) { - return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to))) + return runPromiseInstance(S.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 - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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 }) { - 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() - 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() - - 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), - ) } diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts new file mode 100644 index 0000000000..50485d0a7f --- /dev/null +++ b/packages/opencode/src/snapshot/service.ts @@ -0,0 +1,320 @@ +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 + + 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 + + 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 + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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 }) { + 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() + 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() + + 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), + ) +} diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts new file mode 100644 index 0000000000..70bf29aaf3 --- /dev/null +++ b/packages/opencode/test/effect/runtime.test.ts @@ -0,0 +1,128 @@ +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) + }, + }) + }) +}) diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index ce880d70d9..67af82fc8b 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -34,7 +34,7 @@ export function withServices( project: Instance.project, }), ) - let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + let resolved: Layer.Layer = layer.pipe(Layer.provide(ctx)) as any if (options?.provide) { for (const l of options.provide) { resolved = resolved.pipe(Layer.provide(l)) as any From 12b8e1c2bef555eeedfde9239ea5564233915bf8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 20 Mar 2026 18:38:08 +0000 Subject: [PATCH 004/108] chore: generate --- packages/opencode/src/provider/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index ff3433797a..8ede977a59 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -33,7 +33,8 @@ export namespace ProviderAuth { method: z.number(), inputs: z.record(z.string(), z.string()).optional(), }), - async (input): Promise => runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), + async (input): Promise => + runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) export const callback = fn( From 24f9df5463df3a52a235bb6d9b7929c6764c327d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 14:50:01 -0400 Subject: [PATCH 005/108] fix: update stale account url/email on re-login (#18426) --- packages/opencode/src/account/repo.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 1659546a26..96f980cdad 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service Date: Fri, 20 Mar 2026 15:50:04 -0400 Subject: [PATCH 006/108] fix question cross out --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7b54ec2875..4682c50df1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1667,6 +1667,7 @@ function InlineTool(props: { const denied = createMemo( () => + error()?.includes("QuestionRejectedError") || error()?.includes("rejected permission") || error()?.includes("specified a rule") || error()?.includes("user dismissed"), From 3f249aba6db022bf0736b73d630d11c548f3e946 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 20 Mar 2026 16:36:19 -0400 Subject: [PATCH 007/108] commit and push --- packages/opencode/package.json | 4 ++-- packages/opencode/script/build.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f888cc829b..f9201ddebf 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -89,8 +89,6 @@ "@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:", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", @@ -123,6 +121,7 @@ "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", @@ -133,6 +132,7 @@ "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:", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index c6a83eb736..c330de4535 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -199,6 +199,19 @@ for (const item of targets) { }, }) + // Smoke test: only run if binary is for current platform + if (item.os === process.platform && item.arch === process.arch) { + const binaryPath = `dist/${name}/bin/opencode` + console.log(`Running smoke test: ${binaryPath} --version`) + try { + const versionOutput = await $`${binaryPath} --version`.text() + console.log(`Smoke test passed: ${versionOutput.trim()}`) + } catch (e) { + console.error(`Smoke test failed for ${name}:`, e) + process.exit(1) + } + } + await $`rm -rf ./dist/${name}/bin/tui` await Bun.file(`dist/${name}/package.json`).write( JSON.stringify( From 214a6c6cf13038ae2a6e5a89a3d59fbf23e5be5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 16:55:46 -0400 Subject: [PATCH 008/108] fix: switch consumers to service imports to break bundle cycles (#18438) --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- packages/opencode/src/server/routes/session.ts | 2 +- packages/opencode/src/session/index.ts | 4 ++-- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.sql.ts | 4 ++-- packages/opencode/src/session/system.ts | 2 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/edit.ts | 4 ++-- packages/opencode/src/tool/question.ts | 5 +++-- packages/opencode/src/tool/task.ts | 2 +- packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- 15 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index fd07ebc85b..5a629c73e1 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" -import { PermissionNext } from "@/permission" +import { Permission as PermissionNext } from "@/permission/service" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927a..d069877347 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" -import type { Snapshot } from "@/snapshot" +import type { Snapshot } from "@/snapshot/service" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 613c8b05c1..564bb496b5 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -12,7 +12,7 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot" +import { Snapshot } from "@/snapshot/service" import { Log } from "../../util/log" import { PermissionNext } from "@/permission" import { PermissionID } from "@/permission/schema" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 01fd214e0a..bbb7c97fd2 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -20,7 +20,7 @@ import { Instance } from "../project/instance" import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" -import { Snapshot } from "@/snapshot" +import { Snapshot } from "@/snapshot/service" import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" @@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { PermissionNext } from "@/permission" +import { Permission as PermissionNext } from "@/permission/service" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 748fd3eb21..b28a595e1b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" -import { PermissionNext } from "@/permission" +import { Permission as PermissionNext } from "@/permission/service" import { Auth } from "@/auth" export namespace LLM { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f1335f6f21..3e1816e68e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -4,7 +4,7 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" -import { Snapshot } from "@/snapshot" +import { Snapshot } from "@/snapshot/service" import { fn } from "@/util/fn" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { MessageTable, PartTable, SessionTable } from "./session.sql" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8200dea756..2fe3310ca6 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -13,7 +13,7 @@ import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission" -import { Question } from "@/question" +import { Question } from "@/question/service" import { PartID } from "./schema" import type { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index ea1c4dafb9..f73e16804a 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,8 +1,8 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { Snapshot } from "../snapshot" -import type { PermissionNext } from "../permission" +import type { Snapshot } from "../snapshot/service" +import type { Permission as PermissionNext } from "../permission/service" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 80c89618e3..ead715cfb7 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission" +import { Permission as PermissionNext } from "@/permission/service" import { Skill } from "@/skill" export namespace SystemPrompt { diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 06293b6eba..66c8b181b2 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,7 +12,7 @@ import { trimDiff } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" -import { File } from "../file" +import { File } from "../file/service" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 1a7614fc17..982095cd55 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -9,13 +9,13 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" -import { File } from "../file" +import { File } from "../file/service" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { Snapshot } from "@/snapshot" +import { Snapshot } from "@/snapshot/service" import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index a2887546d4..27a988e563 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" -import { Question } from "../question" +import { Question } from "../question/service" +import { Question as QuestionApi } from "../question" import DESCRIPTION from "./question.txt" export const QuestionTool = Tool.define("question", { @@ -9,7 +10,7 @@ export const QuestionTool = Tool.define("question", { questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"), }), async execute(params, ctx) { - const answers = await Question.ask({ + const answers = await QuestionApi.ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 9cabf47eb1..79bec75602 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" -import { PermissionNext } from "@/permission" +import { Permission as PermissionNext } from "@/permission/service" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index d29af86f83..c34bdbc508 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,7 +1,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" -import type { PermissionNext } from "../permission" +import type { Permission as PermissionNext } from "../permission/service" import type { SessionID, MessageID } from "../session/schema" import { Truncate } from "./truncate" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 83474a543c..abfab6d482 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -5,7 +5,7 @@ import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" -import { File } from "../file" +import { File } from "../file/service" import { FileWatcher } from "../file/watcher" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" From 129fe1e3507f014d5328122d8af875efa89cbbbc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 20 Mar 2026 17:00:05 -0400 Subject: [PATCH 009/108] ci --- packages/opencode/script/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index c330de4535..a97cf32496 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -200,7 +200,7 @@ for (const item of targets) { }) // Smoke test: only run if binary is for current platform - if (item.os === process.platform && item.arch === process.arch) { + if (item.os === process.platform && item.arch === process.arch && !item.abi) { const binaryPath = `dist/${name}/bin/opencode` console.log(`Running smoke test: ${binaryPath} --version`) try { From 45ae7dc6535879a8e42561ceff2c2cedc9b506e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:28:40 +0000 Subject: [PATCH 010/108] Update VOUCHED list https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628 --- .github/VOUCHED.td | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ddfa7fd161..65bd876ece 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -10,6 +10,7 @@ adamdotdevin -agusbasari29 AI PR slop ariane-emory +-danieljoshuanazareth edemaine -florianleibert fwang @@ -17,8 +18,8 @@ iamdavidhill jayair kitlangton kommander +-opencode2026 r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr --OpenCode2026 From dc0044882c465ce96a2452498fb36313bbe953ab Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:34:07 -0500 Subject: [PATCH 011/108] ignore: add danieljoshuanazareth to disavow list (#18476) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 65bd876ece..831f32edbf 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -23,3 +23,4 @@ r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr +-danieljoshuanazareth From 5dc47905a93d01efeaff3de713d9aaea1246dd59 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 20 Mar 2026 22:49:55 -0400 Subject: [PATCH 012/108] allow customizing DB location --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/storage/db.ts | 4 ++++ packages/opencode/test/storage/db.test.ts | 15 ++++++++++----- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 43b3de37bf..05f04c85ce 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -69,6 +69,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] + export const OPENCODE_DB = process.env["OPENCODE_DB"] export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB") export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS") export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS") diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index dcf0942e12..1bb8c1a69b 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -28,6 +28,10 @@ const log = Log.create({ service: "db" }) export namespace Database { export const Path = iife(() => { + if (Flag.OPENCODE_DB) { + if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB + return path.join(Global.Path.data, Flag.OPENCODE_DB) + } const channel = Installation.CHANNEL if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index 601289e58e..af5ddec365 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Global } from "../../src/global" import { Installation } from "../../src/installation" import { Database } from "../../src/storage/db" describe("Database.Path", () => { test("returns database path for the current channel", () => { - const file = path.basename(Database.Path) - const expected = ["latest", "beta"].includes(Installation.CHANNEL) - ? "opencode.db" - : `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db` - expect(file).toBe(expected) + const db = process.env["OPENCODE_DB"] + const expected = db + ? path.isAbsolute(db) + ? db + : path.join(Global.Path.data, db) + : ["latest", "beta"].includes(Installation.CHANNEL) + ? path.join(Global.Path.data, "opencode.db") + : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) + expect(Database.Path).toBe(expected) }) }) From 6a6417758972db9eb08b8534e84f21471899e205 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 23:10:34 -0400 Subject: [PATCH 013/108] fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) --- .../console/app/src/routes/zen/util/handler.ts | 12 ++++++------ .../app/src/routes/zen/util/provider/anthropic.ts | 1 - .../app/src/routes/zen/util/provider/google.ts | 1 - .../routes/zen/util/provider/openai-compatible.ts | 1 - .../app/src/routes/zen/util/provider/openai.ts | 1 - .../app/src/routes/zen/util/provider/provider.ts | 14 +++++++++++++- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 1996d48c56..d51688c994 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -24,7 +24,7 @@ import { FreeUsageLimitError, SubscriptionUsageLimitError, } from "./error" -import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" +import { buildCostChunk, createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" import { anthropicHelper } from "./provider/anthropic" import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" @@ -90,7 +90,7 @@ export async function handler( const projectId = input.request.headers.get("x-opencode-project") ?? "" const ocClient = input.request.headers.get("x-opencode-client") ?? "" logger.metric({ - is_tream: isStream, + is_stream: isStream, session: sessionId, request: requestId, client: ocClient, @@ -230,7 +230,7 @@ export async function handler( const body = JSON.stringify( responseConverter({ ...json, - cost: calculateOccuredCost(billingSource, costInfo), + cost: calculateOccurredCost(billingSource, costInfo), }), ) logger.metric({ response_length: body.length }) @@ -274,8 +274,8 @@ export async function handler( await trialLimiter?.track(usageInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await reload(billingSource, authInfo, costInfo) - const cost = calculateOccuredCost(billingSource, costInfo) - c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost))) + const cost = calculateOccurredCost(billingSource, costInfo) + c.enqueue(encoder.encode(buildCostChunk(opts.format, cost))) } c.close() return @@ -818,7 +818,7 @@ export async function handler( } } - function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) { + function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) { return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0" } diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 15fe75b848..2b617bff62 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -167,7 +167,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => } }, retrieve: () => usage, - buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`, } }, normalizeUsage: (usage: Usage) => ({ diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts index ecf3b2d4d4..f6f7d6e19b 100644 --- a/packages/console/app/src/routes/zen/util/provider/google.ts +++ b/packages/console/app/src/routes/zen/util/provider/google.ts @@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({ usage = json.usageMetadata }, retrieve: () => usage, - buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`, } }, normalizeUsage: (usage: Usage) => { diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 046bf8f0c6..ce97a34d9b 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -54,7 +54,6 @@ export const oaCompatHelper: ProviderHelper = () => ({ usage = json.usage }, retrieve: () => usage, - buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`, } }, normalizeUsage: (usage: Usage) => { diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 596b38cc5a..e5649239e7 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({ usage = json.response.usage }, retrieve: () => usage, - buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`, } }, normalizeUsage: (usage: Usage) => { diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index 1f9492845f..3afabdae90 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string } createUsageParser: () => { parse: (chunk: string) => void retrieve: () => any - buidlCostChunk: (cost: string) => string } normalizeUsage: (usage: any) => UsageInfo } @@ -162,6 +161,19 @@ export interface CommonChunk { } } +export function buildCostChunk(format: ZenData.Format, cost: string): string { + switch (format) { + case "anthropic": + return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n` + case "openai": + return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n` + case "oa-compat": + return `data: ${JSON.stringify({ choices: [], cost })}\n\n` + default: + return `data: ${JSON.stringify({ type: "ping", cost })}\n\n` + } +} + export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) { return (body: any): any => { if (from === to) return body From 40aeaa120d050a0f3395367cbc2acea052bd8fe6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 21 Mar 2026 03:11:28 +0000 Subject: [PATCH 014/108] chore: generate --- packages/console/app/src/routes/zen/util/handler.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index d51688c994..812e7c8d1c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -24,7 +24,13 @@ import { FreeUsageLimitError, SubscriptionUsageLimitError, } from "./error" -import { buildCostChunk, createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" +import { + buildCostChunk, + createBodyConverter, + createStreamPartConverter, + createResponseConverter, + UsageInfo, +} from "./provider/provider" import { anthropicHelper } from "./provider/anthropic" import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" From 38e0dc9ccd18f791ca9d433e5f2d1c1c7178341a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 00:51:35 -0400 Subject: [PATCH 015/108] Move service state into InstanceState, flatten service facades (#18483) --- packages/opencode/script/seed-e2e.ts | 2 - packages/opencode/specs/effect-migration.md | 105 ++- packages/opencode/src/account/effect.ts | 380 ---------- packages/opencode/src/account/index.ts | 397 +++++++++- packages/opencode/src/agent/agent.ts | 44 +- packages/opencode/src/auth/effect.ts | 94 --- packages/opencode/src/auth/index.ts | 124 ++- packages/opencode/src/cli/cmd/account.ts | 11 +- packages/opencode/src/cli/cmd/debug/agent.ts | 12 +- packages/opencode/src/cli/cmd/run.ts | 4 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- packages/opencode/src/config/config.ts | 2 +- .../opencode/src/effect/instance-state.ts | 47 ++ packages/opencode/src/effect/instances.ts | 68 -- packages/opencode/src/effect/run-service.ts | 13 + packages/opencode/src/effect/runtime.ts | 25 - packages/opencode/src/file/index.ts | 706 +++++++++++++++++- packages/opencode/src/file/service.ts | 674 ----------------- packages/opencode/src/file/time-service.ts | 93 --- packages/opencode/src/file/time.ts | 120 ++- packages/opencode/src/file/watcher.ts | 152 ++-- packages/opencode/src/format/index.ts | 182 ++++- packages/opencode/src/format/service.ts | 152 ---- packages/opencode/src/installation/index.ts | 19 +- packages/opencode/src/permission/index.ts | 336 ++++++++- packages/opencode/src/permission/service.ts | 282 ------- packages/opencode/src/project/bootstrap.ts | 8 + packages/opencode/src/project/instance.ts | 8 +- packages/opencode/src/project/vcs.ts | 96 ++- .../opencode/src/provider/auth-service.ts | 215 ------ packages/opencode/src/provider/auth.ts | 268 ++++++- packages/opencode/src/question/index.ts | 218 +++++- packages/opencode/src/question/service.ts | 172 ----- .../opencode/src/server/routes/permission.ts | 10 +- .../opencode/src/server/routes/session.ts | 8 +- packages/opencode/src/server/server.ts | 7 +- packages/opencode/src/session/index.ts | 10 +- packages/opencode/src/session/llm.ts | 8 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/processor.ts | 8 +- packages/opencode/src/session/prompt.ts | 14 +- packages/opencode/src/session/session.sql.ts | 8 +- packages/opencode/src/session/system.ts | 4 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/skill/index.ts | 261 ++++++- packages/opencode/src/skill/service.ts | 238 ------ packages/opencode/src/skill/skill.ts | 35 - packages/opencode/src/snapshot/index.ts | 386 +++++++++- packages/opencode/src/snapshot/service.ts | 320 -------- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/edit.ts | 4 +- packages/opencode/src/tool/question.ts | 5 +- packages/opencode/src/tool/task.ts | 4 +- packages/opencode/src/tool/tool.ts | 4 +- packages/opencode/src/tool/truncate-effect.ts | 137 ---- packages/opencode/src/tool/truncate.ts | 144 +++- packages/opencode/src/tool/write.ts | 2 +- .../opencode/test/account/service.test.ts | 2 +- packages/opencode/test/agent/agent.test.ts | 38 +- packages/opencode/test/config/config.test.ts | 2 +- .../test/effect/instance-state.test.ts | 384 ++++++++++ .../opencode/test/effect/run-service.test.ts | 46 ++ packages/opencode/test/effect/runtime.test.ts | 128 ---- packages/opencode/test/file/index.test.ts | 96 ++- packages/opencode/test/file/time.test.ts | 26 +- packages/opencode/test/file/watcher.test.ts | 6 +- packages/opencode/test/format/format.test.ts | 109 ++- .../opencode/test/permission-task.test.ts | 114 +-- .../opencode/test/permission/next.test.ts | 370 +++++---- .../test/plugin/auth-override.test.ts | 23 +- packages/opencode/test/project/vcs.test.ts | 8 +- .../opencode/test/question/question.test.ts | 131 ++++ .../opencode/test/share/share-next.test.ts | 6 +- packages/opencode/test/skill/skill.test.ts | 6 +- .../opencode/test/snapshot/snapshot.test.ts | 6 +- packages/opencode/test/tool/bash.test.ts | 42 +- packages/opencode/test/tool/edit.test.ts | 6 +- .../test/tool/external-directory.test.ts | 12 +- packages/opencode/test/tool/read.test.ts | 30 +- packages/opencode/test/tool/registry.test.ts | 6 +- packages/opencode/test/tool/skill.test.ts | 10 +- packages/opencode/test/tool/task.test.ts | 6 +- .../opencode/test/tool/truncation.test.ts | 5 +- packages/opencode/test/tool/write.test.ts | 6 +- 84 files changed, 4536 insertions(+), 3742 deletions(-) delete mode 100644 packages/opencode/src/account/effect.ts delete mode 100644 packages/opencode/src/auth/effect.ts create mode 100644 packages/opencode/src/effect/instance-state.ts delete mode 100644 packages/opencode/src/effect/instances.ts create mode 100644 packages/opencode/src/effect/run-service.ts delete mode 100644 packages/opencode/src/effect/runtime.ts delete mode 100644 packages/opencode/src/file/service.ts delete mode 100644 packages/opencode/src/file/time-service.ts delete mode 100644 packages/opencode/src/format/service.ts delete mode 100644 packages/opencode/src/permission/service.ts delete mode 100644 packages/opencode/src/provider/auth-service.ts delete mode 100644 packages/opencode/src/question/service.ts delete mode 100644 packages/opencode/src/skill/service.ts delete mode 100644 packages/opencode/src/skill/skill.ts delete mode 100644 packages/opencode/src/snapshot/service.ts delete mode 100644 packages/opencode/src/tool/truncate-effect.ts create mode 100644 packages/opencode/test/effect/instance-state.test.ts create mode 100644 packages/opencode/test/effect/run-service.test.ts delete mode 100644 packages/opencode/test/effect/runtime.test.ts diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index fc3573548d..f5bd7194f2 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -11,7 +11,6 @@ const seed = async () => { const { Instance } = await import("../src/project/instance") const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Config } = await import("../src/config/config") - const { disposeRuntime } = await import("../src/effect/runtime") const { Session } = await import("../src/session") const { MessageID, PartID } = await import("../src/session/schema") const { Project } = await import("../src/project/project") @@ -55,7 +54,6 @@ const seed = async () => { }) } finally { await Instance.disposeAll().catch(() => {}) - await disposeRuntime().catch(() => {}) } } diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fd..80c906fcc8 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`. ## Choose scope -Use the shared runtime for process-wide services with one lifecycle for the whole app. +Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal. -Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. +Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. -- Shared runtime: config readers, stateless helpers, global clients -- Instance-scoped: watchers, per-project caches, session state, project-bound background work +- Global services (no per-directory state): Account, Auth, Installation, Truncate +- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth -Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. +Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. ## Service shape -For a fully migrated module, use the public namespace directly: +Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions: ```ts export namespace Foo { @@ -28,53 +28,52 @@ export namespace Foo { export const layer = Layer.effect( Service, Effect.gen(function* () { - return Service.of({ - get: Effect.fn("Foo.get")(function* (id) { - return yield* ... - }), + // For instance-scoped services: + const state = yield* InstanceState.make( + Effect.fn("Foo.state")(() => Effect.succeed({ ... })), + ) + + const get = Effect.fn("Foo.get")(function* (id: FooID) { + const s = yield* InstanceState.get(state) + // ... }) + + return Service.of({ get }) }), ) - export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) + // Optional: wire dependencies + export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) + + // Per-service runtime (inside the namespace) + const runPromise = makeRunPromise(Service, defaultLayer) + + // Async facade functions + export async function get(id: FooID) { + return runPromise((svc) => svc.get(id)) + } } ``` Rules: -- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace -- Export `defaultLayer` only when wiring dependencies is useful -- Use the direct namespace form once the module is fully migrated +- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split +- `runPromise` goes inside the namespace (not exported unless tests need it) +- Facade functions are plain `async function` — no `fn()` wrappers +- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing) +- No `Layer.fresh` — InstanceState handles per-directory isolation -## Temporary mixed-mode pattern +## Schema → Zod interop -Prefer a single namespace whenever possible. - -Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles. +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: ```ts -export namespace FooEffect { - export interface Interface { - readonly get: (id: FooID) => Effect.Effect - } +import { zod } from "@/util/effect-zod" - export class Service extends ServiceMap.Service()("@opencode/Foo") {} - - export const layer = Layer.effect(...) -} +export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` -Then keep the old boundary thin: - -```ts -export namespace Foo { - export function get(id: FooID) { - return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id))) - } -} -``` - -Remove the `Effect` suffix when the boundary split is gone. +See `Auth.ZodInfo` for the canonical example. ## Scheduled Tasks @@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow ## Migration checklist -Done now: +Fully migrated (single namespace, InstanceState where needed, flattened facade): -- [x] `AccountEffect` (mixed-mode) -- [x] `AuthEffect` (mixed-mode) -- [x] `TruncateEffect` (mixed-mode) -- [x] `Question` -- [x] `PermissionNext` -- [x] `ProviderAuth` -- [x] `FileWatcher` -- [x] `FileTime` -- [x] `Format` -- [x] `Vcs` -- [x] `Skill` -- [x] `Discovery` -- [x] `File` -- [x] `Snapshot` +- [x] `Account` — `account/index.ts` +- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop) +- [x] `File` — `file/index.ts` +- [x] `FileTime` — `file/time.ts` +- [x] `FileWatcher` — `file/watcher.ts` +- [x] `Format` — `format/index.ts` +- [x] `Installation` — `installation/index.ts` +- [x] `Permission` — `permission/index.ts` +- [x] `ProviderAuth` — `provider/auth.ts` +- [x] `Question` — `question/index.ts` +- [x] `Skill` — `skill/index.ts` +- [x] `Snapshot` — `snapshot/index.ts` +- [x] `Truncate` — `tool/truncate.ts` +- [x] `Vcs` — `project/vcs.ts` +- [x] `Discovery` — `skill/discovery.ts` Still open and likely worth migrating: @@ -130,7 +130,6 @@ Still open and likely worth migrating: - [ ] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` -- [ ] `Installation` - [ ] `Bus` - [ ] `Command` - [ ] `Config` diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts deleted file mode 100644 index 8686ef42a9..0000000000 --- a/packages/opencode/src/account/effect.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" - -import { withTransientReadRetry } from "@/util/effect-http-client" -import { AccountRepo, type AccountRow } from "./repo" -import { - type AccountError, - AccessToken, - AccountID, - DeviceCode, - Info, - RefreshToken, - AccountServiceError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - 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 type AccountOrgs = { - account: Info - orgs: readonly Org[] -} - -class RemoteConfig extends Schema.Class("RemoteConfig")({ - config: Schema.Record(Schema.String, Schema.Json), -}) {} - -const DurationFromSeconds = Schema.Number.pipe( - Schema.decodeTo(Schema.Duration, { - decode: SchemaGetter.transform((n) => Duration.seconds(n)), - encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), - }), -) - -class TokenRefresh extends Schema.Class("TokenRefresh")({ - access_token: AccessToken, - refresh_token: RefreshToken, - expires_in: DurationFromSeconds, -}) {} - -class DeviceAuth extends Schema.Class("DeviceAuth")({ - device_code: DeviceCode, - user_code: UserCode, - verification_uri_complete: Schema.String, - expires_in: DurationFromSeconds, - interval: DurationFromSeconds, -}) {} - -class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ - access_token: AccessToken, - refresh_token: RefreshToken, - token_type: Schema.Literal("Bearer"), - expires_in: DurationFromSeconds, -}) {} - -class DeviceTokenError extends Schema.Class("DeviceTokenError")({ - error: Schema.String, - error_description: Schema.String, -}) { - toPollResult(): PollResult { - if (this.error === "authorization_pending") return new PollPending() - if (this.error === "slow_down") return new PollSlow() - if (this.error === "expired_token") return new PollExpired() - if (this.error === "access_denied") return new PollDenied() - return new PollError({ cause: this.error }) - } -} - -const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) - -class User extends Schema.Class("User")({ - id: AccountID, - email: Schema.String, -}) {} - -class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} - -class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ - grant_type: Schema.String, - device_code: DeviceCode, - client_id: Schema.String, -}) {} - -class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ - grant_type: Schema.String, - refresh_token: RefreshToken, - client_id: Schema.String, -}) {} - -const clientId = "opencode-cli" - -const mapAccountServiceError = - (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError((cause) => - cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), - ), - ) - -export namespace Account { - export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect - readonly config: ( - accountID: AccountID, - orgID: OrgID, - ) => Effect.Effect>, AccountError> - readonly token: (accountID: AccountID) => Effect.Effect, AccountError> - readonly login: (url: string) => Effect.Effect - readonly poll: (input: Login) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Account") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const repo = yield* AccountRepo - const http = yield* HttpClient.HttpClient - const httpRead = withTransientReadRetry(http) - const httpOk = HttpClient.filterStatusOk(http) - const httpReadOk = HttpClient.filterStatusOk(httpRead) - - const executeRead = (request: HttpClientRequest.HttpClientRequest) => - httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => - httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeEffectOk = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => httpOk.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const executeEffect = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => http.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - if (row.token_expiry && row.token_expiry > now) return row.access_token - - const response = yield* executeEffectOk( - HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( - new TokenRefreshRequest({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) - - yield* repo.persistToken({ - accountID: row.id, - accessToken: parsed.access_token, - refreshToken: parsed.refresh_token, - expiry, - }) - - return parsed.access_token - }) - - const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) return Option.none() - - const account = maybeAccount.value - const accessToken = yield* resolveToken(account) - return Option.some({ account, accessToken }) - }) - - const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/user`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const token = Effect.fn("Account.token")((accountID: AccountID) => - resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), - ) - - const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { - const accounts = yield* repo.list() - const [errors, results] = yield* Effect.partition( - accounts, - (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), - { concurrency: 3 }, - ) - for (const error of errors) { - yield* Effect.logWarning("failed to fetch orgs for account").pipe( - Effect.annotateLogs({ error: String(error) }), - ) - } - return results - }) - - const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return [] - - const { account, accessToken } = resolved.value - - return yield* fetchOrgs(account.url, accessToken) - }) - - const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return Option.none() - - const { account, accessToken } = resolved.value - - const response = yield* executeRead( - HttpClientRequest.get(`${account.url}/api/config`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeaders({ "x-org-id": orgID }), - ), - ) - - if (response.status === 404) return Option.none() - - const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) - - const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return Option.some(parsed.config) - }) - - const login = Effect.fn("Account.login")(function* (server: string) { - const response = yield* executeEffectOk( - HttpClientRequest.post(`${server}/auth/device/code`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return new Login({ - code: parsed.device_code, - user: parsed.user_code, - url: `${server}${parsed.verification_uri_complete}`, - server, - expiry: parsed.expires_in, - interval: parsed.interval, - }) - }) - - const poll = Effect.fn("Account.poll")(function* (input: Login) { - const response = yield* executeEffect( - HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( - new DeviceTokenRequest({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: input.code, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - if (parsed instanceof DeviceTokenError) return parsed.toPollResult() - const accessToken = parsed.access_token - - const user = fetchUser(input.server, accessToken) - const orgs = fetchOrgs(input.server, accessToken) - - const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) - - // TODO: When there are multiple orgs, let the user choose - const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() - - const now = yield* Clock.currentTimeMillis - const expiry = now + Duration.toMillis(parsed.expires_in) - const refreshToken = parsed.refresh_token - - yield* repo.persistAccount({ - id: account.id, - email: account.email, - url: input.server, - accessToken, - refreshToken, - expiry, - orgID: firstOrgID, - }) - - return new PollSuccess({ email: account.email }) - }) - - return Service.of({ - active: repo.active, - list: repo.list, - orgsByAccount, - remove: repo.remove, - use: repo.use, - orgs, - config, - token, - login, - poll, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) -} diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 753b80c5f1..0a8d3687a3 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,34 +1,397 @@ -import { Effect, Option } from "effect" +import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" +import { makeRunPromise } from "@/effect/run-service" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { + type AccountError, + AccessToken, + AccountID, + DeviceCode, + Info, + RefreshToken, + AccountServiceError, + Login, + Org, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, + UserCode, +} from "./schema" -export { AccessToken, AccountID, OrgID } from "./effect" +export { + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Info, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" -import { runtime } from "@/effect/runtime" - -function runSync(f: (service: S.Interface) => Effect.Effect) { - return runtime.runSync(S.Service.use(f)) +export type AccountOrgs = { + account: Info + orgs: readonly Org[] } -function runPromise(f: (service: S.Interface) => Effect.Effect) { - return runtime.runPromise(S.Service.use(f)) +class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), +}) {} + +const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), +) + +class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, +}) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } } +const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) + +class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, +}) {} + +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, +}) {} + +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} + +const clientId = "opencode-cli" + +const mapAccountServiceError = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), + ), + ) + export namespace Account { - export const Info = Model - export type Info = Model + export interface Interface { + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect + readonly config: ( + accountID: AccountID, + orgID: OrgID, + ) => Effect.Effect>, AccountError> + readonly token: (accountID: AccountID) => Effect.Effect, AccountError> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect + } - export function active(): Info | undefined { - return Option.getOrUndefined(runSync((service) => service.active())) + export class Service extends ServiceMap.Service()("@opencode/Account") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const repo = yield* AccountRepo + const http = yield* HttpClient.HttpClient + const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeEffectOk = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (row.token_expiry && row.token_expiry > now) return row.access_token + + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) + + yield* repo.persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + }) + + return parsed.access_token + }) + + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) return Option.none() + + const account = maybeAccount.value + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const token = Effect.fn("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), + ) + + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { + const accounts = yield* repo.list() + const [errors, results] = yield* Effect.partition( + accounts, + (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), + { concurrency: 3 }, + ) + for (const error of errors) { + yield* Effect.logWarning("failed to fetch orgs for account").pipe( + Effect.annotateLogs({ error: String(error) }), + ) + } + return results + }) + + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + + const { account, accessToken } = resolved.value + + return yield* fetchOrgs(account.url, accessToken) + }) + + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + + const { account, accessToken } = resolved.value + + const response = yield* executeRead( + HttpClientRequest.get(`${account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeaders({ "x-org-id": orgID }), + ), + ) + + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) + + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return Option.some(parsed.config) + }) + + const login = Effect.fn("Account.login")(function* (server: string) { + const response = yield* executeEffectOk( + HttpClientRequest.post(`${server}/auth/device/code`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return new Login({ + code: parsed.device_code, + user: parsed.user_code, + url: `${server}${parsed.verification_uri_complete}`, + server, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + + const poll = Effect.fn("Account.poll")(function* (input: Login) { + const response = yield* executeEffect( + HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + const accessToken = parsed.access_token + + const user = fetchUser(input.server, accessToken) + const orgs = fetchOrgs(input.server, accessToken) + + const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) + + // TODO: When there are multiple orgs, let the user choose + const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() + + const now = yield* Clock.currentTimeMillis + const expiry = now + Duration.toMillis(parsed.expires_in) + const refreshToken = parsed.refresh_token + + yield* repo.persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken, + refreshToken, + expiry, + orgID: firstOrgID, + }) + + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ + active: repo.active, + list: repo.list, + orgsByAccount, + remove: repo.remove, + use: repo.use, + orgs, + config, + token, + login, + poll, + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) + + export const runPromise = makeRunPromise(Service, defaultLayer) + + export async function active(): Promise { + return Option.getOrUndefined(await runPromise((service) => service.active())) } export async function config(accountID: AccountID, orgID: OrgID): Promise | undefined> { - const config = await runPromise((service) => service.config(accountID, orgID)) - return Option.getOrUndefined(config) + const cfg = await runPromise((service) => service.config(accountID, orgID)) + return Option.getOrUndefined(cfg) } export async function token(accountID: AccountID): Promise { - const token = await runPromise((service) => service.token(accountID)) - return Option.getOrUndefined(token) + const t = await runPromise((service) => service.token(accountID)) + return Option.getOrUndefined(t) } } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5a629c73e1..30d0986144 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" @@ -32,7 +32,7 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: PermissionNext.Ruleset, + permission: Permission.Ruleset, model: z .object({ modelID: ModelID.zod, @@ -54,7 +54,7 @@ export namespace Agent { const skillDirs = await Skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = PermissionNext.fromConfig({ + const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", external_directory: { @@ -72,16 +72,16 @@ export namespace Agent { "*.env.example": "allow", }, }) - const user = PermissionNext.fromConfig(cfg.permission ?? {}) + const user = Permission.fromConfig(cfg.permission ?? {}) const result: Record = { build: { name: "build", description: "The default agent. Executes tools based on configured permissions.", options: {}, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ question: "allow", plan_enter: "allow", }), @@ -94,9 +94,9 @@ export namespace Agent { name: "plan", description: "Plan mode. Disallows all edit tools.", options: {}, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ question: "allow", plan_exit: "allow", external_directory: { @@ -116,9 +116,9 @@ export namespace Agent { general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ todoread: "deny", todowrite: "deny", }), @@ -130,9 +130,9 @@ export namespace Agent { }, explore: { name: "explore", - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", grep: "allow", glob: "allow", @@ -161,9 +161,9 @@ export namespace Agent { native: true, hidden: true, prompt: PROMPT_COMPACTION, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -177,9 +177,9 @@ export namespace Agent { native: true, hidden: true, temperature: 0.5, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -192,9 +192,9 @@ export namespace Agent { options: {}, native: true, hidden: true, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -213,7 +213,7 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", - permission: PermissionNext.merge(defaults, user), + permission: Permission.merge(defaults, user), options: {}, native: false, } @@ -229,7 +229,7 @@ export namespace Agent { item.name = value.name ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) - item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } // Ensure Truncate.GLOB is allowed unless explicitly configured @@ -242,9 +242,9 @@ export namespace Agent { }) if (explicit) continue - result[name].permission = PermissionNext.merge( + result[name].permission = Permission.merge( result[name].permission, - PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), ) } diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts deleted file mode 100644 index 14a9708079..0000000000 --- a/packages/opencode/src/auth/effect.ts +++ /dev/null @@ -1,94 +0,0 @@ -import path from "path" -import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" -import { Global } from "../global" -import { Filesystem } from "../util/filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -export class Oauth extends Schema.Class("OAuth")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: Schema.Number, - accountId: Schema.optional(Schema.String), - enterpriseUrl: Schema.optional(Schema.String), -}) {} - -export class Api extends Schema.Class("ApiAuth")({ - type: Schema.Literal("api"), - key: Schema.String, -}) {} - -export class WellKnown extends Schema.Class("WellKnownAuth")({ - type: Schema.Literal("wellknown"), - key: Schema.String, - token: Schema.String, -}) {} - -export const Info = Schema.Union([Oauth, Api, WellKnown]) -export type Info = Schema.Schema.Type - -export class AuthError extends Schema.TaggedErrorClass()("AuthError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -const file = path.join(Global.Path.data, "auth.json") - -const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) - -export namespace Auth { - export interface Interface { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Auth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const decode = Schema.decodeUnknownOption(Info) - - const all = Effect.fn("Auth.all")(() => - Effect.tryPromise({ - try: async () => { - const data = await Filesystem.readJson>(file).catch(() => ({})) - return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) - }, - catch: fail("Failed to read auth data"), - }), - ) - - const get = Effect.fn("Auth.get")(function* (providerID: string) { - return (yield* all())[providerID] - }) - - const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - if (norm !== key) delete data[key] - delete data[norm + "/"] - yield* Effect.tryPromise({ - try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), - catch: fail("Failed to write auth data"), - }) - }) - - const remove = Effect.fn("Auth.remove")(function* (key: string) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - delete data[key] - delete data[norm] - yield* Effect.tryPromise({ - try: () => Filesystem.writeJson(file, data, 0o600), - catch: fail("Failed to write auth data"), - }) - }) - - return Service.of({ get, all, set, remove }) - }), - ) -} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 411d9dccc0..c50040f1d7 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,43 +1,101 @@ -import { Effect } from "effect" -import z from "zod" -import { runtime } from "@/effect/runtime" -import * as S from "./effect" +import path from "path" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { makeRunPromise } from "@/effect/run-service" +import { zod } from "@/util/effect-zod" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" -export { OAUTH_DUMMY_KEY } from "./effect" +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" -function runPromise(f: (service: S.Auth.Interface) => Effect.Effect) { - return runtime.runPromise(S.Auth.Service.use(f)) -} +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause }) export namespace Auth { - export const Oauth = z - .object({ - type: z.literal("oauth"), - refresh: z.string(), - access: z.string(), - expires: z.number(), - accountId: z.string().optional(), - enterpriseUrl: z.string().optional(), - }) - .meta({ ref: "OAuth" }) + export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), + }) {} - export const Api = z - .object({ - type: z.literal("api"), - key: z.string(), - }) - .meta({ ref: "ApiAuth" }) + export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, + }) {} - export const WellKnown = z - .object({ - type: z.literal("wellknown"), - key: z.string(), - token: z.string(), - }) - .meta({ ref: "WellKnownAuth" }) + export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, + }) {} - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) - export type Info = z.infer + const _Info = Schema.Union([Oauth, Api, WellKnown]) + export const Info = Object.assign(_Info, { zod: zod(_Info) }) + export type Info = Schema.Schema.Type + + export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Auth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(() => + Effect.tryPromise({ + try: async () => { + const data = await Filesystem.readJson>(file).catch(() => ({})) + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }, + catch: fail("Failed to read auth data"), + }), + ) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + const remove = Effect.fn("Auth.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, data, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + return Service.of({ get, all, set, remove }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) export async function get(providerID: string) { return runPromise((service) => service.get(providerID)) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index fb702c95a5..fe8747bce7 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -1,8 +1,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, Account, OrgID, PollExpired, type PollResult } from "@/account" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -160,7 +159,7 @@ export const LoginCommand = cmd({ }), async handler(args) { UI.empty() - await runtime.runPromise(loginEffect(args.url)) + await Account.runPromise((_svc) => loginEffect(args.url)) }, }) @@ -174,7 +173,7 @@ export const LogoutCommand = cmd({ }), async handler(args) { UI.empty() - await runtime.runPromise(logoutEffect(args.email)) + await Account.runPromise((_svc) => logoutEffect(args.email)) }, }) @@ -183,7 +182,7 @@ export const SwitchCommand = cmd({ describe: false, async handler() { UI.empty() - await runtime.runPromise(switchEffect()) + await Account.runPromise((_svc) => switchEffect()) }, }) @@ -192,7 +191,7 @@ export const OrgsCommand = cmd({ describe: false, async handler() { UI.empty() - await runtime.runPromise(orgsEffect()) + await Account.runPromise((_svc) => orgsEffect()) }, }) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index f33dcc5582..7f451e98c0 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "../../../tool/registry" import { Instance } from "../../../project/instance" -import { PermissionNext } from "../../../permission" +import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) { } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { - const disabled = PermissionNext.disabled( + const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, ) @@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) { } await Session.updateMessage(message) - const ruleset = PermissionNext.merge(agent.permission, session.permission ?? []) + const ruleset = Permission.merge(agent.permission, session.permission ?? []) return { sessionID: session.id, @@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) { abort: new AbortController().signal, messages: [], metadata: () => {}, - async ask(req: Omit) { + async ask(req: Omit) { for (const pattern of req.patterns) { - const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) + const rule = Permission.evaluate(req.permission, pattern, ruleset) if (rule.action === "deny") { - throw new PermissionNext.DeniedError({ ruleset }) + throw new Permission.DeniedError({ ruleset }) } } }, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 85b5689daa..0aeb864e86 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" -import { PermissionNext } from "../../permission" +import { Permission } from "../../permission" import { Tool } from "../../tool/tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" @@ -354,7 +354,7 @@ export const RunCommand = cmd({ process.exit(1) } - const rules: PermissionNext.Ruleset = [ + const rules: Permission.Ruleset = [ { permission: "question", action: "deny", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d069877347..3b296a927a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" -import type { Snapshot } from "@/snapshot/service" +import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 47afdfd7d0..c464fcb64a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -177,7 +177,7 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - const active = Account.active() + const active = await Account.active() if (active?.active_org_id) { try { const [config, token] = await Promise.all([ diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts new file mode 100644 index 0000000000..fe3339ee68 --- /dev/null +++ b/packages/opencode/src/effect/instance-state.ts @@ -0,0 +1,47 @@ +import { Effect, ScopedCache, Scope } from "effect" +import { Instance, type Shape } from "@/project/instance" +import { registerDisposer } from "./instance-registry" + +const TypeId = "~opencode/InstanceState" + +export interface InstanceState { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} + +export namespace InstanceState { + export const make = ( + init: (ctx: Shape) => Effect.Effect, + ): Effect.Effect>, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + capacity: Number.POSITIVE_INFINITY, + lookup: () => init(Instance.current), + }) + + const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + return { + [TypeId]: TypeId, + cache, + } + }) + + export const get = (self: InstanceState) => + Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) + + export const use = (self: InstanceState, select: (value: A) => B) => + Effect.map(get(self), select) + + export const useEffect = ( + self: InstanceState, + select: (value: A) => Effect.Effect, + ) => Effect.flatMap(get(self), select) + + export const has = (self: InstanceState) => + Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) + + export const invalidate = (self: InstanceState) => + Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) +} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts deleted file mode 100644 index 6fcfddb24f..0000000000 --- a/packages/opencode/src/effect/instances.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { File } from "@/file/service" -import { FileTime } from "@/file/time-service" -import { FileWatcher } from "@/file/watcher" -import { Format } from "@/format/service" -import { Permission } from "@/permission/service" -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 { InstanceContext } from "./instance-context" -import { registerDisposer } from "./instance-registry" - -export { InstanceContext } from "./instance-context" - -export type InstanceServices = - | Question.Service - | Permission.Service - | ProviderAuth.Service - | FileWatcher.Service - | Vcs.Service - | FileTime.Service - | Format.Service - | File.Service - | Skill.Service - | Snapshot.Service - -// NOTE: LayerMap only passes the key (directory string) to lookup, but we need -// the full instance context (directory, worktree, project). We read from the -// legacy Instance ALS here, which is safe because lookup is only triggered via -// runPromiseInstance -> Instances.get, which always runs inside Instance.provide. -// This should go away once the old Instance type is removed and lookup can load -// the full context directly. -function lookup(_key: string) { - const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) - return Layer.mergeAll( - Question.layer, - Permission.layer, - ProviderAuth.defaultLayer, - FileWatcher.layer, - Vcs.layer, - FileTime.layer, - Format.layer, - File.layer, - Skill.defaultLayer, - Snapshot.defaultLayer, - ).pipe(Layer.provide(ctx)) -} - -export class Instances extends ServiceMap.Service>()( - "opencode/Instances", -) { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) - const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) - yield* Effect.addFinalizer(() => Effect.sync(unregister)) - return Instances.of(layerMap) - }), - ) - - static get(directory: string): Layer.Layer { - return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) - } -} diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts new file mode 100644 index 0000000000..226c276ead --- /dev/null +++ b/packages/opencode/src/effect/run-service.ts @@ -0,0 +1,13 @@ +import { Effect, Layer, ManagedRuntime } from "effect" +import * as ServiceMap from "effect/ServiceMap" + +export const memoMap = Layer.makeMemoMapUnsafe() + +export function makeRunPromise(service: ServiceMap.Service, layer: Layer.Layer) { + let rt: ManagedRuntime.ManagedRuntime | undefined + + return (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => { + rt ??= ManagedRuntime.make(layer, { memoMap }) + return rt.runPromise(service.use(fn), options) + } +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts deleted file mode 100644 index e6f1f32626..0000000000 --- a/packages/opencode/src/effect/runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Effect, Layer, ManagedRuntime } from "effect" -import { Account } from "@/account/effect" -import { Auth } 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 { Instance } from "@/project/instance" - -export const runtime = ManagedRuntime.make( - Layer.mergeAll( - Account.defaultLayer, // - Installation.defaultLayer, - Truncate.defaultLayer, - Instances.layer, - ).pipe(Layer.provideMerge(Auth.layer)), -) - -export function runPromiseInstance(effect: Effect.Effect) { - return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) -} - -export function disposeRuntime() { - return runtime.dispose() -} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 35a5b5e204..23c77e7bf7 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,40 +1,712 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { File as S } from "./service" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-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 - 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 - 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 - export type Interface = S.Interface + export const Event = { + Edited: BusEvent.define( + "file.edited", + z.object({ + file: z.string(), + }), + ), + } - export const Service = S.Service - export const layer = S.layer + 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", + ]) + + 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 = { + 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.debug("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] + } + + interface State { + cache: Entry + fiber: Fiber.Fiber | undefined + } + + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/File") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("File.state")(() => + Effect.succeed({ + cache: { files: [], dirs: [] } as Entry, + fiber: undefined as Fiber.Fiber | undefined, + }), + ), + ) + + const scan = Effect.fn("File.scan")(function* () { + if (Instance.directory === path.parse(Instance.directory).root) return + const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const next: Entry = { files: [], dirs: [] } + + yield* Effect.promise(async () => { + if (isGlobalHome) { + const dirs = new Set() + 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() + 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 + "/") + } + } + } + }) + + const s = yield* InstanceState.get(state) + s.cache = next + }) + + const scope = yield* Scope.Scope + + const ensure = Effect.fn("File.ensure")(function* () { + const s = yield* InstanceState.get(state) + if (!s.fiber) + s.fiber = yield* scan().pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + s.fiber = undefined + }), + ), + Effect.forkIn(scope), + ) + yield* Fiber.join(s.fiber) + }) + + const init = Effect.fn("File.init")(function* () { + yield* ensure() + }) + + 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 => { + 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" + }) { + yield* ensure() + const { cache } = yield* InstanceState.get(state) + + return yield* Effect.promise(async () => { + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const result = cache + const 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 }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) export function init() { - return runPromiseInstance(S.Service.use((svc) => svc.init())) + return runPromise((svc) => svc.init()) } export async function status() { - return runPromiseInstance(S.Service.use((svc) => svc.status())) + return runPromise((svc) => svc.status()) } export async function read(file: string): Promise { - return runPromiseInstance(S.Service.use((svc) => svc.read(file))) + return runPromise((svc) => svc.read(file)) } export async function list(dir?: string) { - return runPromiseInstance(S.Service.use((svc) => svc.list(dir))) + return runPromise((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 runPromise((svc) => svc.search(input)) } } diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts deleted file mode 100644 index d4f6b347f8..0000000000 --- a/packages/opencode/src/file/service.ts +++ /dev/null @@ -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 - - 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 - - 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 - - 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 = { - 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 - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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() - 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() - 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 | 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 => { - 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) -} diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts deleted file mode 100644 index a0fa8bfabf..0000000000 --- a/packages/opencode/src/file/time-service.ts +++ /dev/null @@ -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: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() - - 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* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ).pipe(Layer.orDie, Layer.fresh) -} diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index b6d572fe8b..4962ef0c9e 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,28 +1,128 @@ -import { runPromiseInstance } from "@/effect/runtime" +import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +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: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map() + reads.set(sessionID, next) + return next + } + + interface State { + reads: Map> + locks: Map + } + + export interface Interface { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const state = yield* InstanceState.make( + Effect.fn("FileTime.state")(() => + Effect.succeed({ + reads: new Map>(), + locks: new Map(), + }), + ), + ) + + const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { + const locks = (yield* InstanceState.get(state)).locks + const lock = locks.get(filepath) + if (lock) return lock + + const next = Semaphore.makeUnsafe(1) + locks.set(filepath, next) + return next + }) + + const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { + const reads = (yield* InstanceState.get(state)).reads + log.info("read", { sessionID, file }) + session(reads, sessionID).set(file, yield* stamp(file)) + }) + + const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { + const reads = (yield* InstanceState.get(state)).reads + return reads.get(sessionID)?.get(file)?.read + }) + + const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { + if (disableCheck) return + + const reads = (yield* InstanceState.get(state)).reads + const time = reads.get(sessionID)?.get(filepath) + if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) + + 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* (filepath: string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) + }) + + return Service.of({ read, get, assert, withLock }) + }), + ).pipe(Layer.orDie) + + const runPromise = makeRunPromise(Service, layer) export function read(sessionID: SessionID, file: string) { - return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file))) + return runPromise((s) => s.read(sessionID, file)) } export function get(sessionID: SessionID, file: string) { - return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file))) + return runPromise((s) => s.get(sessionID, file)) } export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) + return runPromise((s) => s.assert(sessionID, filepath)) } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) + return runPromise((s) => s.withLock(filepath, fn)) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 7e5f5f7be3..1b3fc8ab4f 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Cause, Effect, Layer, Scope, ServiceMap } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" @@ -7,7 +7,8 @@ import path from "path" import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { Instance } from "@/project/instance" import { git } from "@/util/git" @@ -60,82 +61,107 @@ export namespace FileWatcher { export const hasNativeBinding = () => !!watcher() - export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {} + export interface Interface { + readonly init: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const instance = yield* InstanceContext - if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) + const state = yield* InstanceState.make( + Effect.fn("FileWatcher.state")( + function* () { + if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return - log.info("init", { directory: instance.directory }) + log.info("init", { directory: Instance.directory }) - const backend = getBackend() - if (!backend) { - log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) - return Service.of({}) - } + const backend = getBackend() + if (!backend) { + log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + return + } - const w = watcher() - if (!w) return Service.of({}) + const w = watcher() + if (!w) return - log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) + log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) - const subs: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe())))) + const subs: ParcelWatcher.AsyncSubscription[] = [] + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), + ) - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { - if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } - }) + const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { + if (err) return + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) - const subscribe = (dir: string, ignore: string[]) => { - const pending = w.subscribe(dir, cb, { ignore, backend }) - return Effect.gen(function* () { - const sub = yield* Effect.promise(() => pending) - subs.push(sub) - }).pipe( - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + const subscribe = (dir: string, ignore: string[]) => { + const pending = w.subscribe(dir, cb, { ignore, backend }) + return Effect.gen(function* () { + const sub = yield* Effect.promise(() => pending) + subs.push(sub) + }).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return Effect.void + }), + ) + } + + const cfg = yield* Effect.promise(() => Config.get()) + const cfgIgnores = cfg.watcher?.ignore ?? [] + + if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + yield* subscribe(Instance.directory, [ + ...FileIgnore.PATTERNS, + ...cfgIgnores, + ...protecteds(Instance.directory), + ]) + } + + if (Instance.project.vcs === "git") { + const result = yield* Effect.promise(() => + git(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }), + ) + const vcsDir = + result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( + (entry) => entry !== "HEAD", + ) + yield* subscribe(vcsDir, ignore) + } + } + }, Effect.catchCause((cause) => { - log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) - pending.then((s) => s.unsubscribe()).catch(() => {}) + log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) return Effect.void }), - ) - } + ), + ) - const cfg = yield* Effect.promise(() => Config.get()) - const cfgIgnores = cfg.watcher?.ignore ?? [] + return Service.of({ + init: Effect.fn("FileWatcher.init")(function* () { + yield* InstanceState.get(state) + }), + }) + }), + ) - if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)]) - } + const runPromise = makeRunPromise(Service, layer) - if (instance.project.vcs === "git") { - const result = yield* Effect.promise(() => - git(["rev-parse", "--git-dir"], { - cwd: instance.project.worktree, - }), - ) - const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( - (entry) => entry !== "HEAD", - ) - yield* subscribe(vcsDir, ignore) - } - } - - return Service.of({}) - }).pipe( - Effect.catchCause((cause) => { - log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.succeed(Service.of({})) - }), - ), - ).pipe(Layer.orDie, Layer.fresh) + export function init() { + return runPromise((svc) => svc.init()) + } } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e4381c69b2..39e0630cfc 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,16 +1,182 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { Format as S } from "./service" +import { Effect, Layer, ServiceMap } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +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 - export const Service = S.Service - export const layer = S.layer + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Format") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Format.state")(function* (_ctx) { + const enabled: Record = {} + const formatters: Record = {} + + 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 matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) + const checks = await Promise.all( + matching.map(async (item) => { + log.info("checking", { name: item.name, ext }) + const on = await isEnabled(item) + if (on) { + log.info("enabled", { name: item.name, ext }) + } + return { + item, + enabled: on, + } + }), + ) + return checks.filter((x) => x.enabled).map((x) => x.item) + } + + 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") + + return { + formatters, + isEnabled, + } + }), + ) + + const init = Effect.fn("Format.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("Format.status")(function* () { + const { formatters, isEnabled } = yield* InstanceState.get(state) + const result: Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + return Service.of({ init, status }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) + + export async function init() { + return runPromise((s) => s.init()) + } export async function status() { - return runPromiseInstance(S.Service.use((s) => s.status())) + return runPromise((s) => s.status()) } } diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts deleted file mode 100644 index 64fff79497..0000000000 --- a/packages/opencode/src/format/service.ts +++ /dev/null @@ -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 - - export interface Interface { - readonly status: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - - const enabled: Record = {} - const formatters: Record = {} - - 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) -} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d0bd103296..1e4e45f2cd 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,7 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { makeRunPromise } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" @@ -293,7 +294,7 @@ export namespace Installation { result = yield* run(["scoop", "install", `opencode@${target}`]) break default: - throw new Error(`Unknown method: ${m}`) + return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` }) } if (!result || result.code !== 0) { const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" @@ -329,27 +330,21 @@ export namespace Installation { Layer.provide(NodePath.layer), ) - // 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(f: (service: Interface) => Effect.Effect) { - const { runtime } = await import("@/effect/runtime") - return runtime.runPromise(Service.use(f)) - } + const runPromise = makeRunPromise(Service, defaultLayer) - export function info(): Promise { + export async function info(): Promise { return runPromise((svc) => svc.info()) } - export function method(): Promise { + export async function method(): Promise { return runPromise((svc) => svc.method()) } - export function latest(installMethod?: Method): Promise { + export async function latest(installMethod?: Method): Promise { return runPromise((svc) => svc.latest(installMethod)) } - export function upgrade(m: Method, target: string): Promise { + export async function upgrade(m: Method, target: string): Promise { return runPromise((svc) => svc.upgrade(m, target)) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 01ac768971..63e6570189 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,52 +1,322 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { fn } from "@/util/fn" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { ProjectID } from "@/project/schema" +import { Instance } from "@/project/instance" +import { MessageID, SessionID } from "@/session/schema" +import { PermissionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" +import { 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 +export namespace Permission { + 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 - 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 - export const Request = S.Request - export type Request = S.Request + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer - 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 - export const Approval = S.Approval - export type Approval = z.infer + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer - 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()("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()("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()("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) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + interface State { + pending: Map + approved: Ruleset + } + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) + } + + export class Service extends ServiceMap.Service()("@opencode/Permission") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Permission.state")(function* (ctx) { + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), + ) + const state = { + pending: new Map(), + approved: row?.data ?? [], + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + const { approved, pending } = yield* InstanceState.get(state) + 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() + 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) { + const { approved, pending } = yield* InstanceState.get(state) + 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* () { + const pending = (yield* InstanceState.get(state)).pending + 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() + } + + const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] + + export function disabled(tools: string[], ruleset: Ruleset): Set { + const result = new Set() + 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 + } + + export const runPromise = makeRunPromise(Service, layer) + + export async function ask(input: z.infer) { + return runPromise((s) => s.ask(input)) + } + + export async function reply(input: z.infer) { + return runPromise((s) => s.reply(input)) + } export async function list() { - return runPromiseInstance(S.Service.use((s) => s.list())) + return runPromise((s) => s.list()) } } diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts deleted file mode 100644 index 08475520b2..0000000000 --- a/packages/opencode/src/permission/service.ts +++ /dev/null @@ -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 - - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer - - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer - - 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 - - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer - - 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()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } - - export class CorrectedError extends Schema.TaggedErrorClass()("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()("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) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - 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()("@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() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - 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() - 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) { - 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 { - const result = new Set() - 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 - } -} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 86403f3da9..a8ad84297a 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,7 +1,11 @@ import { Plugin } from "../plugin" +import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" +import { FileWatcher } from "../file/watcher" +import { Snapshot } from "../snapshot" import { Project } from "./project" +import { Vcs } from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" @@ -12,8 +16,12 @@ export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() ShareNext.init() + Format.init() await LSP.init() File.init() + FileWatcher.init() + Vcs.init() + Snapshot.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 6075540161..4c9b2e107b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -7,13 +7,13 @@ import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" -interface Context { +export interface Shape { directory: string worktree: string project: Project.Info } -const context = Context.create("instance") -const cache = new Map>() +const context = Context.create("instance") +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(directory: string, next: Promise) { const task = next.catch((error) => { if (cache.get(directory) === task) cache.delete(directory) throw error diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9a9e42ecf8..dea25b91b4 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,7 +1,8 @@ import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" import { FileWatcher } from "@/file/watcher" import { Log } from "@/util/log" import { git } from "@/util/git" @@ -30,54 +31,81 @@ export namespace Vcs { export type Info = z.infer export interface Interface { + readonly init: () => Effect.Effect readonly branch: () => Effect.Effect } + interface State { + current: string | undefined + } + export class Service extends ServiceMap.Service()("@opencode/Vcs") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const instance = yield* InstanceContext - let currentBranch: string | undefined + const state = yield* InstanceState.make( + Effect.fn("Vcs.state")((ctx) => + Effect.gen(function* () { + if (ctx.project.vcs !== "git") { + return { current: undefined } + } - if (instance.project.vcs === "git") { - const getCurrentBranch = async () => { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: instance.project.worktree, - }) - if (result.exitCode !== 0) return undefined - const text = result.text().trim() - return text || undefined - } + const getCurrentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: ctx.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } - currentBranch = yield* Effect.promise(() => getCurrentBranch()) - log.info("initialized", { branch: currentBranch }) + const value = { + current: yield* Effect.promise(() => getCurrentBranch()), + } + log.info("initialized", { branch: value.current }) - yield* Effect.acquireRelease( - Effect.sync(() => - Bus.subscribe( - FileWatcher.Event.Updated, - Instance.bind(async (evt) => { - if (!evt.properties.file.endsWith("HEAD")) return - const next = await getCurrentBranch() - if (next !== currentBranch) { - log.info("branch changed", { from: currentBranch, to: next }) - currentBranch = next - Bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ) - } + yield* Effect.acquireRelease( + Effect.sync(() => + Bus.subscribe( + FileWatcher.Event.Updated, + Instance.bind(async (evt) => { + if (!evt.properties.file.endsWith("HEAD")) return + const next = await getCurrentBranch() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ) + + return value + }), + ), + ) return Service.of({ + init: Effect.fn("Vcs.init")(function* () { + yield* InstanceState.get(state) + }), branch: Effect.fn("Vcs.branch")(function* () { - return currentBranch + return yield* InstanceState.use(state, (x) => x.current) }), }) }), - ).pipe(Layer.fresh) + ) + + const runPromise = makeRunPromise(Service, layer) + + export function init() { + return runPromise((svc) => svc.init()) + } + + export function branch() { + return runPromise((svc) => svc.branch()) + } } diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts deleted file mode 100644 index 5045e1edd2..0000000000 --- a/packages/opencode/src/provider/auth-service.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { AuthOuathResult } 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 { - 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 - - 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 - - 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 - | InstanceType - | InstanceType - | InstanceType - - export interface Interface { - readonly methods: () => Effect.Effect> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Auth.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() - - 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 - }) { - 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 }) - }), - ).pipe(Layer.fresh) - - export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) -} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 8ede977a59..2180d30632 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,48 +1,250 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { fn } from "@/util/fn" +import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import { NamedError } from "@opencode-ai/util/error" +import { Auth } from "@/auth" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { Plugin } from "../plugin" import { ProviderID } from "./schema" +import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } 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 - 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 - 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 async function methods() { - return runPromiseInstance(S.Service.use((svc) => svc.methods())) + export const ValidationFailed = NamedError.create( + "ProviderAuthValidationFailed", + z.object({ + field: z.string(), + message: z.string(), + }), + ) + + export type Error = + | Auth.AuthError + | InstanceType + | InstanceType + | InstanceType + | InstanceType + + type Hook = NonNullable + + export interface Interface { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect } - export const authorize = fn( - z.object({ - providerID: ProviderID.zod, - method: z.number(), - inputs: z.record(z.string(), z.string()).optional(), + interface State { + hooks: Record + pending: Map + } + + export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const state = yield* InstanceState.make( + Effect.fn("ProviderAuth.state")(() => + Effect.promise(async () => { + const plugins = await Plugin.list() + return { + hooks: Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), + ), + pending: new Map(), + } + })), + ) + + const methods = Effect.fn("ProviderAuth.methods")(function* () { + const hooks = (yield* InstanceState.get(state)).hooks + 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 + }) { + const { hooks, pending } = yield* InstanceState.get(state) + 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 pending = (yield* InstanceState.get(state)).pending + const match = pending.get(input.providerID) + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + if (match.method === "code" && !input.code) { + 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 }) }), - async (input): Promise => - runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) - export const callback = fn( - z.object({ - providerID: ProviderID.zod, - method: z.number(), - code: z.string().optional(), - }), - async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), - ) + export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function methods() { + return runPromise((svc) => svc.methods()) + } + + export async function authorize(input: { + providerID: ProviderID + method: number + inputs?: Record + }): Promise { + return runPromise((svc) => svc.authorize(input)) + } + + export async function callback(input: { providerID: ProviderID; method: number; code?: string }) { + return runPromise((svc) => svc.callback(input)) + } } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index de00951908..a0d62d94b8 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,49 +1,221 @@ -import { runPromiseInstance } from "@/effect/runtime" -import type { MessageID, SessionID } from "@/session/schema" -import type { QuestionID } from "./schema" -import { Question as S } from "./service" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { SessionID, MessageID } from "@/session/schema" +import { Log } from "@/util/log" +import z from "zod" +import { QuestionID } from "./schema" export namespace Question { - export const Option = S.Option - export type Option = S.Option + const log = Log.create({ service: "question" }) - export const Info = S.Info - export type Info = S.Info + // Schemas - export const Request = S.Request - export type Request = S.Request + 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 - export const Answer = S.Answer - export type Answer = S.Answer + 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 - export const Reply = S.Reply - export type Reply = S.Reply + 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 - export const Event = S.Event - export const RejectedError = S.RejectedError + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer - export type Interface = S.Interface + 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 - export const Service = S.Service - export const layer = S.layer + 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()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + interface State { + pending: Map + } + + // Service + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Question.state")(function* () { + const state = { + pending: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Question.ask")(function* (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) { + const pending = (yield* InstanceState.get(state)).pending + const id = QuestionID.ascending() + log.info("asking", { id, questions: input.questions.length }) + + const deferred = yield* Deferred.make() + 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 pending = (yield* InstanceState.get(state)).pending + 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 pending = (yield* InstanceState.get(state)).pending + 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* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (x) => x.info) + }) + + return Service.of({ ask, reply, reject, list }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromiseInstance(S.Service.use((s) => s.ask(input))) + return runPromise((s) => s.ask(input)) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { - return runPromiseInstance(S.Service.use((s) => s.reply(input))) + return runPromise((s) => s.reply(input)) } export async function reject(requestID: QuestionID) { - return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) + return runPromise((s) => s.reject(requestID)) } export async function list() { - return runPromiseInstance(S.Service.use((s) => s.list())) + return runPromise((s) => s.list()) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts deleted file mode 100644 index a23703e97a..0000000000 --- a/packages/opencode/src/question/service.ts +++ /dev/null @@ -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 - - 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 - - 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 - - export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) - export type Answer = z.infer - - 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 - - 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()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - // Service - - export interface Interface { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Question") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const pending = new Map() - - 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() - 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) -} diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts index cc6c26d435..aae9a9c3a6 100644 --- a/packages/opencode/src/server/routes/permission.ts +++ b/packages/opencode/src/server/routes/permission.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() => requestID: PermissionID.zod, }), ), - validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), + validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") - await PermissionNext.reply({ + await Permission.reply({ requestID: params.requestID, reply: json.reply, message: json.message, @@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() => description: "List of pending permissions", content: { "application/json": { - schema: resolver(PermissionNext.Request.array()), + schema: resolver(Permission.Request.array()), }, }, }, }, }), async (c) => { - const permissions = await PermissionNext.list() + const permissions = await Permission.list() return c.json(permissions) }, ), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 564bb496b5..e399636ad8 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -12,9 +12,9 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { Log } from "../../util/log" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" @@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() => permissionID: PermissionID.zod, }), ), - validator("json", z.object({ response: PermissionNext.Reply })), + validator("json", z.object({ response: Permission.Reply })), async (c) => { const params = c.req.valid("param") - PermissionNext.reply({ + Permission.reply({ requestID: params.permissionID, reply: c.req.valid("json").response, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a68becb1fb..7ead4df8a3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -12,9 +12,8 @@ import { Format } from "../format" import { TuiRoutes } from "./routes/tui" import { Instance } from "../project/instance" import { Vcs } from "../project/vcs" -import { runPromiseInstance } from "@/effect/runtime" import { Agent } from "../agent/agent" -import { Skill } from "../skill/skill" +import { Skill } from "../skill" import { Auth } from "../auth" import { Flag } from "../flag/flag" import { Command } from "../command" @@ -152,7 +151,7 @@ export namespace Server { providerID: ProviderID.zod, }), ), - validator("json", Auth.Info), + validator("json", Auth.Info.zod), async (c) => { const providerID = c.req.valid("param").providerID const info = c.req.valid("json") @@ -331,7 +330,7 @@ export namespace Server { }, }), async (c) => { - const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) + const branch = await Vcs.branch() return c.json({ branch, }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bbb7c97fd2..f2d436ff10 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -20,7 +20,7 @@ import { Instance } from "../project/instance" import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" @@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" @@ -148,7 +148,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), - permission: PermissionNext.Ruleset.optional(), + permission: Permission.Ruleset.optional(), revert: z .object({ messageID: MessageID.zod, @@ -300,7 +300,7 @@ export namespace Session { parentID?: SessionID workspaceID?: WorkspaceID directory: string - permission?: PermissionNext.Ruleset + permission?: Permission.Ruleset }) { const result: Info = { id: SessionID.descending(input.id), @@ -423,7 +423,7 @@ export namespace Session { export const setPermission = fn( z.object({ sessionID: SessionID.zod, - permission: PermissionNext.Ruleset, + permission: Permission.Ruleset, }), async (input) => { return Database.use((db) => { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b28a595e1b..a8009c49d4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Auth } from "@/auth" export namespace LLM { @@ -33,7 +33,7 @@ export namespace LLM { sessionID: string model: Provider.Model agent: Agent.Info - permission?: PermissionNext.Ruleset + permission?: Permission.Ruleset system: string[] abort: AbortSignal messages: ModelMessage[] @@ -286,9 +286,9 @@ export namespace LLM { } async function resolveTools(input: Pick) { - const disabled = PermissionNext.disabled( + const disabled = Permission.disabled( Object.keys(input.tools), - PermissionNext.merge(input.agent.permission, input.permission ?? []), + Permission.merge(input.agent.permission, input.permission ?? []), ) for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3e1816e68e..f1335f6f21 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -4,7 +4,7 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { MessageTable, PartTable, SessionTable } from "./session.sql" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2fe3310ca6..c3a572f5b3 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,8 +12,8 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" -import { PermissionNext } from "@/permission" -import { Question } from "@/question/service" +import { Permission } from "@/permission" +import { Question } from "@/question" import { PartID } from "./schema" import type { SessionID, MessageID } from "./schema" @@ -163,7 +163,7 @@ export namespace SessionProcessor { ) ) { const agent = await Agent.get(input.assistantMessage.agent) - await PermissionNext.ask({ + await Permission.ask({ permission: "doom_loop", patterns: [value.toolName], sessionID: input.assistantMessage.sessionID, @@ -219,7 +219,7 @@ export namespace SessionProcessor { }) if ( - value.error instanceof PermissionNext.RejectedError || + value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError ) { blocked = shouldBreak diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bac958ec10..5625c571ce 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,7 +41,7 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" @@ -168,7 +168,7 @@ export namespace SessionPrompt { // this is backwards compatibility for allowing `tools` to be specified when // prompting - const permissions: PermissionNext.Ruleset = [] + const permissions: Permission.Ruleset = [] for (const [tool, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: tool, @@ -437,10 +437,10 @@ export namespace SessionPrompt { } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart }, async ask(req) { - await PermissionNext.ask({ + await Permission.ask({ ...req, sessionID: sessionID, - ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), }) }, } @@ -781,11 +781,11 @@ export namespace SessionPrompt { } }, async ask(req) { - await PermissionNext.ask({ + await Permission.ask({ ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), }) }, }) @@ -1271,7 +1271,7 @@ export namespace SessionPrompt { if (part.type === "agent") { // Check if this agent would be denied by task permission - const perm = PermissionNext.evaluate("task", part.name, agent.permission) + const perm = Permission.evaluate("task", part.name, agent.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index f73e16804a..189a596873 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,8 +1,8 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { Snapshot } from "../snapshot/service" -import type { Permission as PermissionNext } from "../permission/service" +import type { Snapshot } from "../snapshot" +import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" @@ -31,7 +31,7 @@ export const SessionTable = sqliteTable( summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), + permission: text({ mode: "json" }).$type(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", { .primaryKey() .references(() => ProjectTable.id, { onDelete: "cascade" }), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index ead715cfb7..ca324652d9 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Skill } from "@/skill" export namespace SystemPrompt { @@ -53,7 +53,7 @@ export namespace SystemPrompt { } export async function skills(agent: Agent.Info) { - if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return + if (Permission.disabled(["skill"], agent.permission).has("skill")) return const list = await Skill.available(agent) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index e911656c90..e331e8fc6a 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -45,7 +45,7 @@ export namespace ShareNext { }> { const headers: Record = {} - const active = Account.active() + const active = await Account.active() if (!active?.active_org_id) { const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") return { headers, api: legacyApi, baseUrl } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 67bef3bd38..b770ab83cb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1 +1,260 @@ -export * from "./skill" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import z from "zod" +import { Effect, Layer, ServiceMap } from "effect" +import { NamedError } from "@opencode-ai/util/error" +import type { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Permission } from "@/permission" +import { Filesystem } from "@/util/filesystem" +import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" +import { Log } from "../util/log" +import { Discovery } from "./discovery" + +export namespace Skill { + const log = Log.create({ service: "skill" }) + const EXTERNAL_DIRS = [".claude", ".agents"] + const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" + const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" + const SKILL_PATTERN = "**/SKILL.md" + + export const Info = z.object({ + name: z.string(), + description: z.string(), + location: z.string(), + content: z.string(), + }) + export type Info = z.infer + + export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), + ) + + export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), + ) + + type State = { + skills: Record + dirs: Set + task?: Promise + } + + type Cache = State & { + ensure: () => Promise + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } + + const add = async (state: State, match: string) => { + const md = await ConfigMarkdown.parse(match).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }) + + if (!md) return + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + if (state.skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: state.skills[parsed.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } + } + + const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { + return Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }) + .then((matches) => Promise.all(matches.map((match) => add(state, match)))) + .catch((error) => { + if (!opts?.scope) throw error + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) + }) + } + + // TODO: Migrate to Effect + const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => { + const state: State = { + skills: {}, + dirs: new Set(), + } + + const load = async () => { + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(await Filesystem.isDir(root))) continue + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + for await (const root of Filesystem.up({ + targets: EXTERNAL_DIRS, + start: directory, + stop: worktree, + })) { + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + for (const dir of await Config.directories()) { + await scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = await Config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(await Filesystem.isDir(dir))) { + log.warn("skill path not found", { path: dir }) + continue + } + + await scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + for (const dir of await Effect.runPromise(discovery.pull(url))) { + state.dirs.add(dir) + await scan(state, dir, SKILL_PATTERN) + } + } + + log.info("init", { count: Object.keys(state.skills).length }) + } + + const ensure = () => { + if (state.task) return state.task + state.task = load().catch((err) => { + state.task = undefined + throw err + }) + return state.task + } + + return { ...state, ensure } + } + + export class Service extends ServiceMap.Service()("@opencode/Skill") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const state = yield* InstanceState.make(Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree)))) + + const ensure = Effect.fn("Skill.ensure")(function* () { + const cache = yield* InstanceState.get(state) + yield* Effect.promise(() => cache.ensure()) + return cache + }) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const cache = yield* ensure() + return cache.skills[name] + }) + + const all = Effect.fn("Skill.all")(function* () { + const cache = yield* ensure() + return Object.values(cache.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + const cache = yield* ensure() + return Array.from(cache.dirs) + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const cache = yield* ensure() + const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), + ) + + export const defaultLayer: Layer.Layer = 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 [ + "", + ...list.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + } + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function get(name: string) { + return runPromise((skill) => skill.get(name)) + } + + export async function all() { + return runPromise((skill) => skill.all()) + } + + export async function dirs() { + return runPromise((skill) => skill.dirs()) + } + + export async function available(agent?: Agent.Info) { + return runPromise((skill) => skill.available(agent)) + } +} diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts deleted file mode 100644 index 434a51bad9..0000000000 --- a/packages/opencode/src/skill/service.ts +++ /dev/null @@ -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 - - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) - - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) - - type State = { - skills: Record - dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - 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(), - } - - 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()("@opencode/Skill") {} - - export const layer: Layer.Layer = 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 = 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 [ - "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") - } -} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts deleted file mode 100644 index ed3e0a4b75..0000000000 --- a/packages/opencode/src/skill/skill.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { runPromiseInstance } from "@/effect/runtime" -import type { Agent } from "@/agent/agent" -import { Skill as S } from "./service" - -export namespace Skill { - export const Info = S.Info - export type Info = S.Info - - export const InvalidError = S.InvalidError - export const NameMismatchError = S.NameMismatchError - - export type Interface = S.Interface - - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer - - export const fmt = S.fmt - - export async function get(name: string) { - return runPromiseInstance(S.Service.use((skill) => skill.get(name))) - } - - export async function all() { - return runPromiseInstance(S.Service.use((skill) => skill.all())) - } - - export async function dirs() { - return runPromiseInstance(S.Service.use((skill) => skill.dirs())) - } - - export async function available(agent?: Agent.Info) { - return runPromiseInstance(S.Service.use((skill) => skill.available(agent))) - } -} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4f845ca2de..5f8c5aeffd 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,44 +1,396 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { Snapshot as S } from "./service" +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 { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-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 - export const FileDiff = S.FileDiff - export type FileDiff = S.FileDiff + 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 - export type Interface = S.Interface + 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"] - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer + interface GitResult { + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string + } + + type State = Omit + + export interface Interface { + readonly init: () => Effect.Effect + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} + + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id), + vcs: ctx.project.vcs, + } + + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [text, stderr] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + ], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* Effect.promise(() => Config.get())).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* () { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + if (!file) { + yield* fs.writeFileString(target, "").pipe(Effect.orDie) + return + } + yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory }) + }) + + const cleanup = Effect.fnUntraced(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) + }) + + const track = Effect.fnUntraced(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash + }) + + const patch = Effect.fnUntraced(function* (hash: string) { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + }) + + const restore = Effect.fnUntraced(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) { + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + const rel = path.relative(state.worktree, file) + const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* remove(file) + } + } + } + } + }) + + const diff = Effect.fnUntraced(function* (hash: string) { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }) + + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") + } + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [adds, dels, file] = line.split("\t") + if (!file) continue + const binary = adds === "-" && dels === "-" + const [before, after] = binary + ? ["", ""] + : yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + result.push({ + file, + before, + after, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status: status.get(file) ?? "modified", + }) + } + + return result + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) + + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner + Layer.provide(NodePath.layer), + ) + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function init() { + return runPromise((svc) => svc.init()) + } export async function cleanup() { - return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) + return runPromise((svc) => svc.cleanup()) } export async function track() { - return runPromiseInstance(S.Service.use((svc) => svc.track())) + return runPromise((svc) => svc.track()) } export async function patch(hash: string) { - return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) + return runPromise((svc) => svc.patch(hash)) } export async function restore(snapshot: string) { - return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot))) + return runPromise((svc) => svc.restore(snapshot)) } export async function revert(patches: Patch[]) { - return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) + return runPromise((svc) => svc.revert(patches)) } export async function diff(hash: string) { - return runPromiseInstance(S.Service.use((svc) => svc.diff(hash))) + return runPromise((svc) => svc.diff(hash)) } export async function diffFull(from: string, to: string) { - return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to))) + return runPromise((svc) => svc.diffFull(from, to)) } } diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts deleted file mode 100644 index 50485d0a7f..0000000000 --- a/packages/opencode/src/snapshot/service.ts +++ /dev/null @@ -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 - - 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 - - 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 - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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 }) { - 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() - 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() - - 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), - ) -} diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 66c8b181b2..06293b6eba 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,7 +12,7 @@ import { trimDiff } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" -import { File } from "../file/service" +import { File } from "../file" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 982095cd55..1a7614fc17 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -9,13 +9,13 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" -import { File } from "../file/service" +import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 27a988e563..a2887546d4 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,7 +1,6 @@ import z from "zod" import { Tool } from "./tool" -import { Question } from "../question/service" -import { Question as QuestionApi } from "../question" +import { Question } from "../question" import DESCRIPTION from "./question.txt" export const QuestionTool = Tool.define("question", { @@ -10,7 +9,7 @@ export const QuestionTool = Tool.define("question", { questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"), }), async execute(params, ctx) { - const answers = await QuestionApi.ask({ + const answers = await Question.ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 79bec75602..e3781126d0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { // Filter agents by permissions if agent provided const caller = ctx?.agent const accessibleAgents = caller - ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") + ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") : agents const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c34bdbc508..6c3f4efaf6 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,7 +1,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" -import type { Permission as PermissionNext } from "../permission/service" +import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" import { Truncate } from "./truncate" @@ -23,7 +23,7 @@ export namespace Tool { extra?: { [key: string]: any } messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): void - ask(input: Omit): Promise + ask(input: Omit): Promise } export interface Info { id: string diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts deleted file mode 100644 index 1b4c6577f3..0000000000 --- a/packages/opencode/src/tool/truncate-effect.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" -import path from "path" -import type { Agent } from "../agent/agent" -import { AppFileSystem } from "@/filesystem" -import { evaluate } from "@/permission/evaluate" -import { Identifier } from "../id/id" -import { Log } from "../util/log" -import { ToolID } from "./schema" -import { TRUNCATION_DIR } from "./truncation-dir" - -export namespace Truncate { - const log = Log.create({ service: "truncation" }) - const RETENTION = Duration.days(7) - - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 - export const DIR = TRUNCATION_DIR - export const GLOB = path.join(TRUNCATION_DIR, "*") - - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - function hasTaskTool(agent?: Agent.Info) { - if (!agent?.permission) return false - return evaluate("task", "*", agent.permission).action !== "deny" - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - /** - * Returns output unchanged when it fits within the limits, otherwise writes the full text - * to the truncation directory and returns a preview plus a hint to inspect the saved file. - */ - readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Truncate") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) - const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( - Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), - Effect.catch(() => Effect.succeed([])), - ) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) - } - }) - - const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } as const - } - - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - - return { - content: - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, - truncated: true, - outputPath: file, - } as const - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, output }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) -} diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 1710546383..fa1d0a4aed 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,18 +1,144 @@ +import { NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" +import path from "path" import type { Agent } from "../agent/agent" -import { runtime } from "@/effect/runtime" -import { Truncate as S } from "./truncate-effect" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import { evaluate } from "@/permission/evaluate" +import { Identifier } from "../id/id" +import { Log } from "../util/log" +import { ToolID } from "./schema" +import { TRUNCATION_DIR } from "./truncation-dir" export namespace Truncate { - export const MAX_LINES = S.MAX_LINES - export const MAX_BYTES = S.MAX_BYTES - export const DIR = S.DIR - export const GLOB = S.GLOB + const log = Log.create({ service: "truncation" }) + const RETENTION = Duration.days(7) - export type Result = S.Result + export const MAX_LINES = 2000 + export const MAX_BYTES = 50 * 1024 + export const DIR = TRUNCATION_DIR + export const GLOB = path.join(TRUNCATION_DIR, "*") - export type Options = S.Options + export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + + export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" + } + + function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return evaluate("task", "*", agent.permission).action !== "deny" + } + + export interface Interface { + readonly cleanup: () => Effect.Effect + /** + * Returns output unchanged when it fits within the limits, otherwise writes the full text + * to the truncation directory and returns a preview plus a hint to inspect the saved file. + */ + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Truncate") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const cleanup = Effect.fn("Truncate.cleanup")(function* () { + const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) + const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const direction = options.direction ?? "head" + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } as const + } + + const out: string[] = [] + let i = 0 + let bytes = 0 + let hitBytes = false + + if (direction === "head") { + for (i = 0; i < lines.length && i < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, output }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { - return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent))) + return runPromise((s) => s.output(text, options, agent)) } } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index abfab6d482..83474a543c 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -5,7 +5,7 @@ import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" -import { File } from "../file/service" +import { File } from "../file" import { FileWatcher } from "../file/watcher" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index f5436e5147..9c67641d20 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -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 { Account } from "../../src/account" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 60c8e57c92..2805cf2614 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,16 +1,20 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" -import { PermissionNext } from "../../src/permission" +import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern -function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { +function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { if (!agent) return undefined - return PermissionNext.evaluate(permission, "*", agent.permission).action + return Permission.evaluate(permission, "*", agent.permission).action } +afterEach(async () => { + await Instance.disposeAll() +}) + test("returns default native agents when no config", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { // Wildcard is denied expect(evalPerm(plan, "edit")).toBe("deny") // But specific path is allowed - expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") }, }) }) @@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy fn: async () => { const explore = await Agent.get("explore") expect(explore).toBeDefined() - expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") + expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") }, }) }) @@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => { const build = await Agent.get("build") expect(build).toBeDefined() // Specific pattern is denied - expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") + expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") // Edit still allowed expect(evalPerm(build, "edit")).toBe("allow") }, @@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) @@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) @@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => { directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") }, }) }) @@ -582,7 +586,7 @@ description: Permission skill. const build = await Agent.get("build") const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") const target = path.join(skillDir, "reference", "notes.md") - expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") }, }) } finally { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index baf209d860..eb9c763fa7 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () => const originalToken = Account.token const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts new file mode 100644 index 0000000000..2d527482ba --- /dev/null +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -0,0 +1,384 @@ +import { afterEach, expect, test } from "bun:test" +import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" +import { InstanceState } from "../../src/effect/instance-state" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +async function access(state: InstanceState, dir: string) { + return Instance.provide({ + directory: dir, + fn: () => Effect.runPromise(InstanceState.get(state)), + }) +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +test("InstanceState caches values per directory", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) + + const a = yield* Effect.promise(() => access(state, tmp.path)) + const b = yield* Effect.promise(() => access(state, tmp.path)) + + expect(a).toBe(b) + expect(n).toBe(1) + }), + ), + ) +}) + +test("InstanceState isolates directories", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + + const a = yield* Effect.promise(() => access(state, one.path)) + const b = yield* Effect.promise(() => access(state, two.path)) + const c = yield* Effect.promise(() => access(state, one.path)) + + expect(a).toBe(c) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), + ), + ) +}) + +test("InstanceState invalidates on reload", async () => { + await using tmp = await tmpdir() + const seen: string[] = [] + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), + ), + ) + + const a = yield* Effect.promise(() => access(state, tmp.path)) + yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) + const b = yield* Effect.promise(() => access(state, tmp.path)) + + expect(a).not.toBe(b) + expect(seen).toEqual(["1"]) + }), + ), + ) +}) + +test("InstanceState invalidates on disposeAll", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + const seen: string[] = [] + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.acquireRelease( + Effect.sync(() => ({ dir: ctx.directory })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) + + yield* Effect.promise(() => access(state, one.path)) + yield* Effect.promise(() => access(state, two.path)) + yield* Effect.promise(() => Instance.disposeAll()) + + expect(seen.sort()).toEqual([one.path, two.path].sort()) + }), + ), + ) +}) + +test("InstanceState.get reads the current directory lazily", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/InstanceStateLazy") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + const get = InstanceState.get(state) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* get + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const a = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + const b = await Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + + expect(a).toBe(one.path) + expect(b).toBe(two.path) + } finally { + await rt.dispose() + } +}) + +test("InstanceState preserves directory across async boundaries", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + await using three = await tmpdir({ git: true }) + + interface Api { + readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> + } + + class Test extends ServiceMap.Service()("@test/InstanceStateAsync") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.sync(() => ({ + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project.id, + })), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + yield* Effect.promise(() => Bun.sleep(1)) + yield* Effect.sleep(Duration.millis(1)) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + yield* Effect.sleep(Duration.millis(2)) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const [a, b, c] = await Promise.all([ + Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + Instance.provide({ + directory: three.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ]) + + expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project }) + expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project }) + expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project }) + expect(a.project).not.toBe(b.project) + expect(a.project).not.toBe(c.project) + expect(b.project).not.toBe(c.project) + } finally { + await rt.dispose() + } +}) + +test("InstanceState survives high-contention concurrent access", async () => { + const N = 20 + const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir())) + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/HighContention") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + // Interleave many async hops to maximize chance of ALS corruption + for (let i = 0; i < 10; i++) { + yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) + yield* Effect.yieldNow + yield* Effect.promise(() => Promise.resolve()) + } + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const results = await Promise.all( + dirs.map((d) => + Instance.provide({ + directory: d.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ), + ) + + for (let i = 0; i < N; i++) { + expect(results[i]).toBe(dirs[i].path) + } + } finally { + await rt.dispose() + for (const d of dirs) await d[Symbol.asyncDispose]() + } +}) + +test("InstanceState correct after interleaved init and dispose", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/InterleavedDispose") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.promise(async () => { + await Bun.sleep(5) // slow init + return ctx.directory + }), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + // Init both directories + const a = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + expect(a).toBe(one.path) + + // Dispose one directory, access the other concurrently + const [, b] = await Promise.all([ + Instance.reload({ directory: one.path }), + Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ]) + expect(b).toBe(two.path) + + // Re-access disposed directory - should get fresh state + const c = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + expect(c).toBe(one.path) + } finally { + await rt.dispose() + } +}) + +test("InstanceState mutation in one directory does not leak to another", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + + // Mutate state in directory one + const s1 = yield* Effect.promise(() => access(state, one.path)) + s1.count = 42 + + // Access directory two — should be independent + const s2 = yield* Effect.promise(() => access(state, two.path)) + expect(s2.count).toBe(0) + + // Confirm directory one still has the mutation + const s1again = yield* Effect.promise(() => access(state, one.path)) + expect(s1again.count).toBe(42) + expect(s1again).toBe(s1) // same reference + }), + ), + ) +}) + +test("InstanceState dedupes concurrent lookups", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => + Effect.promise(async () => { + n += 1 + await Bun.sleep(10) + return { n } + }), + ) + + const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) + expect(a).toBe(b) + expect(n).toBe(1) + }), + ), + ) +}) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts new file mode 100644 index 0000000000..c9f630585e --- /dev/null +++ b/packages/opencode/test/effect/run-service.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { Effect, Layer, ServiceMap } from "effect" +import { makeRunPromise } from "../../src/effect/run-service" + +class Shared extends ServiceMap.Service()("@test/Shared") {} + +test("makeRunPromise shares dependent layers through the shared memo map", async () => { + let n = 0 + + const shared = Layer.effect( + Shared, + Effect.sync(() => { + n += 1 + return Shared.of({ id: n }) + }), + ) + + class One extends ServiceMap.Service Effect.Effect }>()("@test/One") {} + const one = Layer.effect( + One, + Effect.gen(function* () { + const svc = yield* Shared + return One.of({ + get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + class Two extends ServiceMap.Service Effect.Effect }>()("@test/Two") {} + const two = Layer.effect( + Two, + Effect.gen(function* () { + const svc = yield* Shared + return Two.of({ + get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + const runOne = makeRunPromise(One, one) + const runTwo = makeRunPromise(Two, two) + + expect(await runOne((svc) => svc.get())).toBe(1) + expect(await runTwo((svc) => svc.get())).toBe(1) + expect(n).toBe(1) +}) diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts deleted file mode 100644 index 70bf29aaf3..0000000000 --- a/packages/opencode/test/effect/runtime.test.ts +++ /dev/null @@ -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) - }, - }) - }) -}) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 8f4cbe8688..fae3ac1f28 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import { $ } from "bun" import path from "path" import fs from "fs/promises" @@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("file/index Filesystem patterns", () => { describe("File.read() - text content", () => { test("reads text file via Filesystem.readText()", async () => { @@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => { }) }) + test("search works before explicit init", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.search({ query: "main", type: "file" }) + expect(result.some((f) => f.includes("main"))).toBe(true) + }, + }) + }) + test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() @@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => { }, }) }) + + test("search refreshes after init when files change", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + expect(await File.search({ query: "fresh", type: "file" })).toEqual([]) + + await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") + + const result = await File.search({ query: "fresh", type: "file" }) + expect(result).toContain("fresh.ts") + }, + }) + }) }) describe("File.read() - diff/patch", () => { @@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => { }) }) }) + + describe("InstanceState isolation", () => { + test("two directories get independent file caches", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") + await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") + + await Instance.provide({ + directory: one.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "a.ts", type: "file" }) + expect(results).toContain("a.ts") + const results2 = await File.search({ query: "b.ts", type: "file" }) + expect(results2).not.toContain("b.ts") + }, + }) + + await Instance.provide({ + directory: two.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "b.ts", type: "file" }) + expect(results).toContain("b.ts") + const results2 = await File.search({ query: "a.ts", type: "file" }) + expect(results2).not.toContain("a.ts") + }, + }) + }) + + test("disposal gives fresh state on next access", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "before", type: "file" }) + expect(results).toContain("before.ts") + }, + }) + + await Instance.disposeAll() + + await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") + await fs.rm(path.join(tmp.path, "before.ts")) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "after", type: "file" }) + expect(results).toContain("after.ts") + const stale = await File.search({ query: "before", type: "file" }) + expect(stale).not.toContain("before.ts") + }, + }) + }) + }) }) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index fbf8d5cd1e..db7eaaae0d 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -afterEach(() => Instance.disposeAll()) +afterEach(async () => { + await Instance.disposeAll() +}) async function touch(file: string, time: number) { const date = new Date(time) @@ -84,6 +86,28 @@ describe("file/time", () => { }, }) }) + + test("isolates reads by directory", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + await using shared = await tmpdir() + const filepath = path.join(shared.path, "file.txt") + await fs.writeFile(filepath, "content", "utf-8") + + await Instance.provide({ + directory: one.path, + fn: async () => { + await FileTime.read(sessionID, filepath) + }, + }) + + await Instance.provide({ + directory: two.path, + fn: async () => { + expect(await FileTime.get(sessionID, filepath)).toBeUndefined() + }, + }) + }) }) describe("assert()", () => { diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2cd27643e8..f4f0c1c7d6 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -25,7 +25,7 @@ function withWatcher(directory: string, body: Effect.Effect) { directory, FileWatcher.layer, async (rt) => { - await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) + await rt.runPromise(FileWatcher.Service.use((s) => s.init())) await Effect.runPromise(ready(directory)) await Effect.runPromise(body) }, @@ -136,7 +136,9 @@ function ready(directory: string) { // --------------------------------------------------------------------------- describeWatcher("FileWatcher", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("publishes root create, update, and delete events", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 2718e125d0..1992dede62 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -2,11 +2,16 @@ import { Effect } from "effect" import { afterEach, describe, expect, test } from "bun:test" import { tmpdir } from "../fixture/fixture" import { withServices } from "../fixture/instance" +import { Bus } from "../../src/bus" +import { File } from "../../src/file" import { Format } from "../../src/format" +import * as Formatter from "../../src/format/formatter" import { Instance } from "../../src/project/instance" describe("Format", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("status() returns built-in formatters when no config overrides", async () => { await using tmp = await tmpdir() @@ -62,4 +67,106 @@ describe("Format", () => { await rt.runPromise(Format.Service.use(() => Effect.void)) }) }) + + test("status() initializes formatter state per directory", async () => { + await using off = await tmpdir({ + config: { formatter: false }, + }) + await using on = await tmpdir() + + const a = await Instance.provide({ + directory: off.path, + fn: () => Format.status(), + }) + const b = await Instance.provide({ + directory: on.path, + fn: () => Format.status(), + }) + + expect(a).toEqual([]) + expect(b.length).toBeGreaterThan(0) + }) + + test("runs enabled checks for matching formatters in parallel", async () => { + await using tmp = await tmpdir() + + const file = `${tmp.path}/test.parallel` + await Bun.write(file, "x") + + const one = { + extensions: Formatter.gofmt.extensions, + enabled: Formatter.gofmt.enabled, + command: Formatter.gofmt.command, + } + const two = { + extensions: Formatter.mix.extensions, + enabled: Formatter.mix.enabled, + command: Formatter.mix.command, + } + + let active = 0 + let max = 0 + + Formatter.gofmt.extensions = [".parallel"] + Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.command = ["sh", "-c", "true"] + Formatter.mix.command = ["sh", "-c", "true"] + Formatter.gofmt.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return true + } + Formatter.mix.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return true + } + + try { + await withServices(tmp.path, Format.layer, async (rt) => { + await rt.runPromise(Format.Service.use((s) => s.init())) + await Bus.publish(File.Event.Edited, { file }) + }) + } finally { + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.gofmt.command = one.command + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled + Formatter.mix.command = two.command + } + + expect(max).toBe(2) + }) + + test("runs matching formatters sequentially for the same file", async () => { + await using tmp = await tmpdir({ + config: { + formatter: { + first: { + command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"], + extensions: [".seq"], + }, + second: { + command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"], + extensions: [".seq"], + }, + }, + }, + }) + + const file = `${tmp.path}/test.seq` + await Bun.write(file, "x") + + await withServices(tmp.path, Format.layer, async (rt) => { + await rt.runPromise(Format.Service.use((s) => s.init())) + await Bus.publish(File.Event.Edited, { file }) + }) + + expect(await Bun.file(file).text()).toBe("xAB") + }) }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index c78da6e6a5..3ca32bf414 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,11 +1,15 @@ -import { describe, test, expect } from "bun:test" -import { PermissionNext } from "../src/permission" +import { afterEach, describe, test, expect } from "bun:test" +import { Permission } from "../src/permission" import { Config } from "../src/config/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" -describe("PermissionNext.evaluate for permission.task", () => { - const createRuleset = (rules: Record): PermissionNext.Ruleset => +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("Permission.evaluate for permission.task", () => { + const createRuleset = (rules: Record): Permission.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, @@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => { })) test("returns ask when no match (default)", () => { - expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask") }) test("returns deny for explicit deny", () => { const ruleset = createRuleset({ "code-reviewer": "deny" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") }) test("returns allow for explicit allow", () => { const ruleset = createRuleset({ "code-reviewer": "allow" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") }) test("returns ask for explicit ask", () => { const ruleset = createRuleset({ "code-reviewer": "ask" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") }) test("matches wildcard patterns with deny", () => { const ruleset = createRuleset({ "orchestrator-*": "deny" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") }) test("matches wildcard patterns with allow", () => { const ruleset = createRuleset({ "orchestrator-*": "allow" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") }) test("matches wildcard patterns with ask", () => { const ruleset = createRuleset({ "orchestrator-*": "ask" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") const globalRuleset = createRuleset({ "*": "ask" }) - expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") }) test("later rules take precedence (last match wins)", () => { @@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => { "orchestrator-*": "deny", "orchestrator-fast": "allow", }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") }) test("matches global wildcard", () => { - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") }) }) -describe("PermissionNext.disabled for task tool", () => { +describe("Permission.disabled for task tool", () => { // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. - const createRuleset = (rules: Record): PermissionNext.Ruleset => + const createRuleset = (rules: Record): Permission.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, @@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "allow", "*": "deny", }) - const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset) + const disabled = Permission.disabled(["task", "bash", "read"], ruleset) // The task tool IS disabled because there's a pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(true) }) @@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "ask", "*": "deny", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The task tool IS disabled because there's a pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(true) }) test("task tool is disabled when global deny pattern exists", () => { const ruleset = createRuleset({ "*": "deny" }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) }) @@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "deny", general: "deny", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(false) }) test("task tool is enabled when no task rules exist (default ask)", () => { - const disabled = PermissionNext.disabled(["task"], []) + const disabled = Permission.disabled(["task"], []) expect(disabled.has("task")).toBe(false) }) @@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => { "*": "deny", "orchestrator-coder": "allow", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The disabled() function uses findLast and checks if the last matching rule // has pattern: "*" and action: "deny". In this case, the last rule matching // "task" permission has pattern "orchestrator-coder", not "*", so not disabled @@ -155,11 +159,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // general and orchestrator-fast should be allowed, code-reviewer denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") }, }) }) @@ -180,11 +184,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // general and code-reviewer should be ask, orchestrator-* denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") }, }) }) @@ -205,11 +209,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + const ruleset = Permission.fromConfig(config.permission ?? {}) + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Unspecified agents default to "ask" - expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") }, }) }) @@ -232,18 +236,18 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Verify other tool permissions - expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask") // Verify disabled tools - const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset) + const disabled = Permission.disabled(["bash", "edit", "task"], ruleset) expect(disabled.has("bash")).toBe(false) expect(disabled.has("edit")).toBe(false) // task is NOT disabled because disabled() uses findLast, and the last rule @@ -270,16 +274,16 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny") // Since "*": "deny" is the last rule, disabled() finds it with findLast // and sees pattern: "*" with action: "deny", so task is disabled - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) }, }) @@ -301,17 +305,17 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") // Other agents still denied by the earlier "*" deny - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny" // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*" // So the task tool is NOT disabled (even though most subagents are denied) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(false) }, }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 2a6b6e0baf..043e3257b6 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,11 +1,7 @@ import { afterEach, test, expect } from "bun:test" import os from "os" -import { Effect } from "effect" import { Bus } from "../../src/bus" -import { runtime } from "../../src/effect/runtime" -import { Instances } from "../../src/effect/instances" -import { PermissionNext } from "../../src/permission" -import { PermissionNext as S } from "../../src/permission" +import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -16,8 +12,8 @@ afterEach(async () => { }) async function rejectAll(message?: string) { - for (const req of await PermissionNext.list()) { - await PermissionNext.reply({ + for (const req of await Permission.list()) { + await Permission.reply({ requestID: req.id, reply: "reject", message, @@ -27,22 +23,22 @@ async function rejectAll(message?: string) { async function waitForPending(count: number) { for (let i = 0; i < 20; i++) { - const list = await PermissionNext.list() + const list = await Permission.list() if (list.length === count) return list await Bun.sleep(0) } - return PermissionNext.list() + return Permission.list() } // fromConfig tests test("fromConfig - string value becomes wildcard rule", () => { - const result = PermissionNext.fromConfig({ bash: "allow" }) + const result = Permission.fromConfig({ bash: "allow" }) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) }) test("fromConfig - object value converts to rules array", () => { - const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) + const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } }) expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, @@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => { }) test("fromConfig - mixed string and object values", () => { - const result = PermissionNext.fromConfig({ + const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" }, edit: "allow", webfetch: "ask", @@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => { }) test("fromConfig - empty object", () => { - const result = PermissionNext.fromConfig({}) + const result = Permission.fromConfig({}) expect(result).toEqual([]) }) test("fromConfig - expands tilde to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) }) test("fromConfig - expands $HOME to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) }) test("fromConfig - expands $HOME without trailing slash", () => { - const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) + const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) }) test("fromConfig - does not expand tilde in middle of path", () => { - const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) }) test("fromConfig - expands exact tilde to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "~": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) }) test("evaluate - matches expanded tilde pattern", () => { - const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } }) + const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) test("evaluate - matches expanded $HOME pattern", () => { - const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) + const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) // merge tests test("merge - simple concatenation", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "deny" }], ) @@ -119,7 +115,7 @@ test("merge - simple concatenation", () => { }) test("merge - adds new permission", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "edit", pattern: "*", action: "deny" }], ) @@ -130,7 +126,7 @@ test("merge - adds new permission", () => { }) test("merge - concatenates rules for same permission", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "foo", action: "ask" }], [{ permission: "bash", pattern: "*", action: "deny" }], ) @@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => { }) test("merge - multiple rulesets", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "rm", action: "ask" }], [{ permission: "edit", pattern: "*", action: "allow" }], @@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => { }) test("merge - empty ruleset does nothing", () => { - const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) + const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) }) test("merge - preserves rule order", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [ { permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/secret/*", action: "deny" }, @@ -175,40 +171,40 @@ test("merge - preserves rule order", () => { test("merge - config permission overrides default ask", () => { // Simulates: defaults have "*": "ask", config sets bash: "allow" - const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const merged = PermissionNext.merge(defaults, config) + const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const merged = Permission.merge(defaults, config) // Config's bash allow should override default ask - expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow") + expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") // Other permissions should still be ask (from defaults) - expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask") + expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask") }) test("merge - config ask overrides default allow", () => { // Simulates: defaults have bash: "allow", config sets bash: "ask" - const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] - const merged = PermissionNext.merge(defaults, config) + const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] + const merged = Permission.merge(defaults, config) // Config's ask should override default allow - expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask") + expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") }) // evaluate tests test("evaluate - exact pattern match", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - wildcard pattern match", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) expect(result.action).toBe("allow") }) test("evaluate - last matching rule wins", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, ]) @@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => { }) test("evaluate - last matching rule wins (wildcard after specific)", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => { }) test("evaluate - glob pattern match", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ - { permission: "edit", pattern: "src/*", action: "allow" }, - ]) + const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }]) expect(result.action).toBe("allow") }) test("evaluate - last matching glob wins", () => { - const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/components/*", action: "allow" }, ]) @@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => { test("evaluate - order matters for specificity", () => { // If more specific rule comes first, later wildcard overrides it - const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "deny" }, ]) @@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => { }) test("evaluate - unknown permission returns ask", () => { - const result = PermissionNext.evaluate("unknown_tool", "anything", [ + const result = Permission.evaluate("unknown_tool", "anything", [ { permission: "bash", pattern: "*", action: "allow" }, ]) expect(result.action).toBe("ask") }) test("evaluate - empty ruleset returns ask", () => { - const result = PermissionNext.evaluate("bash", "rm", []) + const result = Permission.evaluate("bash", "rm", []) expect(result.action).toBe("ask") }) test("evaluate - no matching pattern returns ask", () => { - const result = PermissionNext.evaluate("edit", "etc/passwd", [ - { permission: "edit", pattern: "src/*", action: "allow" }, - ]) + const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }]) expect(result.action).toBe("ask") }) test("evaluate - empty rules array returns ask", () => { - const result = PermissionNext.evaluate("bash", "rm", []) + const result = Permission.evaluate("bash", "rm", []) expect(result.action).toBe("ask") }) test("evaluate - multiple matching patterns, last wins", () => { - const result = PermissionNext.evaluate("edit", "src/secret.ts", [ + const result = Permission.evaluate("edit", "src/secret.ts", [ { permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/secret.ts", action: "deny" }, @@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => { }) test("evaluate - non-matching patterns are skipped", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + const result = Permission.evaluate("edit", "src/foo.ts", [ { permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "test/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, @@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => { }) test("evaluate - exact match at end wins over earlier wildcard", () => { - const result = PermissionNext.evaluate("bash", "/bin/rm", [ + const result = Permission.evaluate("bash", "/bin/rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "/bin/rm", action: "deny" }, ]) @@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => { }) test("evaluate - wildcard at end overrides earlier exact match", () => { - const result = PermissionNext.evaluate("bash", "/bin/rm", [ + const result = Permission.evaluate("bash", "/bin/rm", [ { permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => { // wildcard permission tests test("evaluate - wildcard permission matches any permission", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - wildcard permission with specific pattern", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - glob permission pattern", () => { - const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ + const result = Permission.evaluate("mcp_server_tool", "anything", [ { permission: "mcp_*", pattern: "*", action: "allow" }, ]) expect(result.action).toBe("allow") }) test("evaluate - specific permission and wildcard permission combined", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "*", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => { }) test("evaluate - wildcard permission does not match when specific exists", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + const result = Permission.evaluate("edit", "src/foo.ts", [ { permission: "*", pattern: "*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, ]) @@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () => }) test("evaluate - multiple matching permission patterns combine rules", () => { - const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ + const result = Permission.evaluate("mcp_dangerous", "anything", [ { permission: "*", pattern: "*", action: "ask" }, { permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_dangerous", pattern: "*", action: "deny" }, @@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => { }) test("evaluate - wildcard permission fallback for unknown tool", () => { - const result = PermissionNext.evaluate("unknown_tool", "anything", [ + const result = Permission.evaluate("unknown_tool", "anything", [ { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => { test("evaluate - permission patterns sorted by length regardless of object order", () => { // specific permission listed before wildcard, but specific should still win - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "deny" }, ]) @@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order }) test("evaluate - merges multiple rulesets", () => { - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] // approved comes after config, so rm should be denied - const result = PermissionNext.evaluate("bash", "rm", config, approved) + const result = Permission.evaluate("bash", "rm", config, approved) expect(result.action).toBe("deny") }) // disabled tests test("disabled - returns empty set when all tools allowed", () => { - const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) + const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) expect(result.size).toBe(0) }) test("disabled - disables tool when denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "read"], [ { permission: "*", pattern: "*", action: "allow" }, @@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => { }) test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["edit", "write", "apply_patch", "multiedit", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, @@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () }) test("disabled - does not disable when partially denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "*", action: "allow" }, @@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => { }) test("disabled - does not disable when action is ask", () => { - const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) + const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) expect(result.size).toBe(0) }) test("disabled - does not disable when specific allow after wildcard deny", () => { // Tool is NOT disabled because a specific allow after wildcard deny means // there's at least some usage allowed - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "*", action: "deny" }, @@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () = }) test("disabled - does not disable when wildcard allow after deny", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "rm *", action: "deny" }, @@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => { }) test("disabled - disables multiple tools", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "webfetch"], [ { permission: "bash", pattern: "*", action: "deny" }, @@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => { }) test("disabled - wildcard permission denies all tools", () => { - const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) + const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) expect(result.has("bash")).toBe(true) expect(result.has("edit")).toBe(true) expect(result.has("read")).toBe(true) }) test("disabled - specific allow overrides wildcard deny", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "read"], [ { permission: "*", pattern: "*", action: "deny" }, @@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => { directory: tmp.path, fn: async () => { await expect( - PermissionNext.ask({ + Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["rm -rf /"], @@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + ).rejects.toBeInstanceOf(Permission.DeniedError) }, }) }) @@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const promise = PermissionNext.ask({ + const promise = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => { ruleset: [], }) - const list = await PermissionNext.list() + const list = await Permission.list() expect(list).toHaveLength(1) expect(list[0]).toMatchObject({ sessionID: SessionID.make("session_test"), @@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - let seen: PermissionNext.Request | undefined - const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + let seen: Permission.Request | undefined + const unsub = Bus.subscribe(Permission.Event.Asked, (event) => { seen = event.properties }) - const ask = PermissionNext.ask({ + const ask = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => { ruleset: [], }) - expect(await PermissionNext.list()).toHaveLength(1) + expect(await Permission.list()).toHaveLength(1) expect(seen).toBeDefined() expect(seen).toMatchObject({ sessionID: SessionID.make("session_test"), @@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test1"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test1"), reply: "once", }) @@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test2"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test2"), reply: "reject", }) - await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) + await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError) }, }) }) @@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ id: PermissionID.make("per_test2b"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test2b"), reply: "reject", message: "Use a safer command", }) const err = await ask.catch((err) => err) - expect(err).toBeInstanceOf(PermissionNext.CorrectedError) + expect(err).toBeInstanceOf(Permission.CorrectedError) expect(err.message).toContain("Use a safer command") }, }) @@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test3"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test3"), reply: "always", }) @@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => { directory: tmp.path, fn: async () => { // Stored approval should allow without asking - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test2"), permission: "bash", patterns: ["ls"], @@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise1 = PermissionNext.ask({ + const askPromise1 = Permission.ask({ id: PermissionID.make("per_test4a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => { ruleset: [], }) - const askPromise2 = PermissionNext.ask({ + const askPromise2 = Permission.ask({ id: PermissionID.make("per_test4b"), sessionID: SessionID.make("session_same"), permission: "edit", @@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => { const result2 = askPromise2.catch((e) => e) // Reject the first one - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test4a"), reply: "reject", }) // Both should be rejected - expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) - expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) + expect(await result1).toBeInstanceOf(Permission.RejectedError) + expect(await result2).toBeInstanceOf(Permission.RejectedError) }, }) }) @@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async await Instance.provide({ directory: tmp.path, fn: async () => { - const a = PermissionNext.ask({ + const a = Permission.ask({ id: PermissionID.make("per_test5a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async ruleset: [], }) - const b = PermissionNext.ask({ + const b = Permission.ask({ id: PermissionID.make("per_test5b"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async await waitForPending(2) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test5a"), reply: "always", }) await expect(a).resolves.toBeUndefined() await expect(b).resolves.toBeUndefined() - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const a = PermissionNext.ask({ + const a = Permission.ask({ id: PermissionID.make("per_test6a"), sessionID: SessionID.make("session_a"), permission: "bash", @@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => { ruleset: [], }) - const b = PermissionNext.ask({ + const b = Permission.ask({ id: PermissionID.make("per_test6b"), sessionID: SessionID.make("session_b"), permission: "bash", @@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => { await waitForPending(2) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test6a"), reply: "always", }) await expect(a).resolves.toBeUndefined() - expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) await rejectAll() await b.catch(() => {}) @@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ id: PermissionID.make("per_test7"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => { | { sessionID: SessionID requestID: PermissionID - reply: PermissionNext.Reply + reply: Permission.Reply } | undefined - const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { + const unsub = Bus.subscribe(Permission.Event.Replied, (event) => { seen = event.properties }) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test7"), reply: "once", }) @@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => { }) }) +test("permission requests stay isolated by directory", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + const a = Instance.provide({ + directory: one.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dir_a"), + sessionID: SessionID.make("session_dir_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + + const b = Instance.provide({ + directory: two.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dir_b"), + sessionID: SessionID.make("session_dir_b"), + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + + const onePending = await Instance.provide({ + directory: one.path, + fn: () => waitForPending(1), + }) + const twoPending = await Instance.provide({ + directory: two.path, + fn: () => waitForPending(1), + }) + + expect(onePending).toHaveLength(1) + expect(twoPending).toHaveLength(1) + expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) + expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) + + await Instance.provide({ + directory: one.path, + fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }), + }) + await Instance.provide({ + directory: two.path, + fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }), + }) + + await a.catch(() => {}) + await b.catch(() => {}) +}) + +test("pending permission rejects on instance dispose", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dispose"), + sessionID: SessionID.make("session_dispose"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await Instance.dispose() + }, + }) + + expect(await result).toBeInstanceOf(Permission.RejectedError) +}) + +test("pending permission rejects on instance reload", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await Instance.reload({ directory: tmp.path }) + }, + }) + + expect(await result).toBeInstanceOf(Permission.RejectedError) +}) + test("reply - does nothing for unknown requestID", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_unknown"), reply: "once", }) - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => { directory: tmp.path, fn: async () => { await expect( - PermissionNext.ask({ + Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + ).rejects.toBeInstanceOf(Permission.DeniedError) }, }) }) @@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "ls -la", "pwd"], @@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const err = await PermissionNext.ask({ + const err = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => { (err) => err, ) - expect(err).toBeInstanceOf(PermissionNext.DeniedError) - expect(await PermissionNext.list()).toHaveLength(0) + expect(err).toBeInstanceOf(Permission.DeniedError) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => { directory: tmp.path, fn: async () => { const ctl = new AbortController() - const ask = runtime.runPromise( - S.Service.use((svc) => + const ask = Permission.runPromise( + (svc) => svc.ask({ sessionID: SessionID.make("session_test"), permission: "bash", @@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], }), - ).pipe(Effect.provide(Instances.get(Instance.directory))), { signal: ctl.signal }, ) @@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => { await ask.catch(() => {}) try { - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) } finally { await rejectAll() } diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 0095ff3875..b967262254 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -31,15 +31,26 @@ describe("plugin.auth-override", () => { }, }) - await Instance.provide({ + await using plain = await tmpdir() + + const methods = await Instance.provide({ directory: tmp.path, fn: async () => { - const methods = await ProviderAuth.methods() - const copilot = methods[ProviderID.make("github-copilot")] - expect(copilot).toBeDefined() - expect(copilot.length).toBe(1) - expect(copilot[0].label).toBe("Test Override Auth") + return ProviderAuth.methods() }, }) + + const plainMethods = await Instance.provide({ + directory: plain.path, + fn: async () => { + return ProviderAuth.methods() + }, + }) + + const copilot = methods[ProviderID.make("github-copilot")] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }, 30000) // Increased timeout for plugin installation }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 90f445ed78..11463b7950 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -25,8 +25,8 @@ function withVcs( directory, Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { - await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) - await rt.runPromise(Vcs.Service.use(() => Effect.void)) + await rt.runPromise(FileWatcher.Service.use((s) => s.init())) + await rt.runPromise(Vcs.Service.use((s) => s.init())) await Bun.sleep(500) await body(rt) }, @@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { // --------------------------------------------------------------------------- describeVcs("Vcs", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("branch() returns current branch name", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 45e0d3c318..adfeda395a 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => { }, }) }) + +test("questions stay isolated by directory", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + const p1 = Instance.provide({ + directory: one.path, + fn: () => + Question.ask({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }), + }) + + const p2 = Instance.provide({ + directory: two.path, + fn: () => + Question.ask({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }), + }) + + const onePending = await Instance.provide({ + directory: one.path, + fn: () => Question.list(), + }) + const twoPending = await Instance.provide({ + directory: two.path, + fn: () => Question.list(), + }) + + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + + await Instance.provide({ + directory: one.path, + fn: () => Question.reject(onePending[0].id), + }) + await Instance.provide({ + directory: two.path, + fn: () => Question.reject(twoPending[0].id), + }) + + await p1.catch(() => {}) + await p2.catch(() => {}) +}) + +test("pending question rejects on instance dispose", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => { + return Question.ask({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }) + }, + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await Question.list() + expect(pending).toHaveLength(1) + await Instance.dispose() + }, + }) + + expect(await result).toBeInstanceOf(Question.RejectedError) +}) + +test("pending question rejects on instance reload", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => { + return Question.ask({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }) + }, + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await Question.list() + expect(pending).toHaveLength(1) + await Instance.reload({ directory: tmp.path }) + }, + }) + + expect(await result).toBeInstanceOf(Question.RejectedError) +}) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 5be5d02450..fc8d511509 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async const originalActive = Account.active const originalConfigGet = Config.get - Account.active = mock(() => undefined) + Account.active = mock(async () => undefined) Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } })) try { @@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act const originalActive = Account.active const originalToken = Account.token - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", @@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => { const originalActive = Account.active const originalToken = Account.token - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 2264723a09..12e16f86a1 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,10 +1,14 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { Skill } from "../../src/skill" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" +afterEach(async () => { + await Instance.disposeAll() +}) + async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") await fs.mkdir(skillDir, { recursive: true }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 2030502876..bf54feb472 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" @@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture" // This helper does the same for expected values so assertions match cross-platform. const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") +afterEach(async () => { + await Instance.disposeAll() +}) + async function bootstrap() { return tmpdir({ git: true, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index a5c7cec917..4d680d494f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import { Truncate } from "../../src/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" @@ -49,10 +49,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -76,10 +76,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -104,10 +104,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -130,10 +130,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -163,10 +163,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -193,10 +193,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -223,10 +223,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -250,10 +250,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -276,10 +276,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -297,10 +297,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 7b6784cf49..f6b1ee5c92 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { EditTool } from "../../src/tool/edit" @@ -18,6 +18,10 @@ const ctx = { ask: async () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + async function touch(file: string, time: number) { const date = new Date(time) await fs.utimes(file, date, date) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 229901a722..0188cbada0 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,7 +3,7 @@ import path from "path" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { @@ -18,7 +18,7 @@ const baseCtx: Omit = { describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => { }) test("no-ops for paths inside Instance.directory", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => { }) test("asks with a single canonical glob", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses target directory when kind=directory", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => { }) test("skips prompting when bypass=true", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index cfeb597fce..06a7f9a706 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,15 +1,19 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission" +import { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") +afterEach(async () => { + await Instance.disposeAll() +}) + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -65,10 +69,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -91,10 +95,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -112,10 +116,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -138,10 +142,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -176,14 +180,14 @@ describe("tool.read env file permissions", () => { let askedForEnv = false const ctxWithPermissions = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { for (const pattern of req.patterns) { - const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission) + const rule = Permission.evaluate(req.permission, pattern, agent.permission) if (rule.action === "ask" && req.permission === "read") { askedForEnv = true } if (rule.action === "deny") { - throw new PermissionNext.DeniedError({ ruleset: agent.permission }) + throw new Permission.DeniedError({ ruleset: agent.permission }) } } }, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12ca..c9951ef198 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.registry", () => { test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index f622341d33..ffae223f98 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" @@ -18,6 +18,10 @@ const baseCtx: Omit = { metadata: () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.skill", () => { test("description lists skill location URL", async () => { await using tmp = await tmpdir({ @@ -133,7 +137,7 @@ Use this skill. directory: tmp.path, fn: async () => { const tool = await SkillTool.init() - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index df319d8de1..aae48a30ab 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" import { TaskTool } from "../../src/tool/task" import { tmpdir } from "../fixture/fixture" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.task", () => { test("description sorts subagents by name and is stable across calls", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 032f0bfee2..dba083c512 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,8 +1,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 { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" @@ -129,7 +128,7 @@ describe("Truncate", () => { }) test("loads truncate effect in a fresh process", async () => { - const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], { + const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], { cwd: ROOT, }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index af002a3910..97939c1051 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" @@ -17,6 +17,10 @@ const ctx = { ask: async () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.write", () => { describe("new file creation", () => { test("writes content to new file", async () => { From 2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 21 Mar 2026 04:52:23 +0000 Subject: [PATCH 016/108] chore: generate --- packages/opencode/src/provider/auth.ts | 23 +- packages/opencode/src/skill/index.ts | 4 +- packages/opencode/test/format/format.test.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 264 ++++--- packages/sdk/openapi.json | 697 +++++++++---------- 6 files changed, 500 insertions(+), 500 deletions(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 2180d30632..99184c48a5 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -118,18 +118,19 @@ export namespace ProviderAuth { const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(() => Effect.promise(async () => { - const plugins = await Plugin.list() - return { - hooks: Record.fromEntries( - Arr.filterMap(plugins, (x) => - x.auth?.provider !== undefined - ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) - : Result.failVoid, + const plugins = await Plugin.list() + return { + hooks: Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), ), - ), - pending: new Map(), - } - })), + pending: new Map(), + } + }), + ), ) const methods = Effect.fn("ProviderAuth.methods")(function* () { diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b770ab83cb..43a22219ed 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -184,7 +184,9 @@ export namespace Skill { Service, Effect.gen(function* () { const discovery = yield* Discovery.Service - const state = yield* InstanceState.make(Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree)))) + const state = yield* InstanceState.make( + Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))), + ) const ensure = Effect.fn("Skill.ensure")(function* () { const cache = yield* InstanceState.get(state) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 1992dede62..68fe71e03f 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -148,11 +148,11 @@ describe("Format", () => { config: { formatter: { first: { - command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"], + command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"], extensions: [".seq"], }, second: { - command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"], + command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"], extensions: [".seq"], }, }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b6821322e2..df46c66f20 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,11 +4,11 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, + ApiAuth, AppAgentsResponses, AppLogErrors, AppLogResponses, AppSkillsResponses, - Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, @@ -63,6 +63,7 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, + OAuth, OutputFormat, Part as Part2, PartDeleteErrors, @@ -173,6 +174,7 @@ import type { TuiShowToastResponses, TuiSubmitPromptResponses, VcsGetResponses, + WellKnownAuth, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -337,7 +339,7 @@ export class Auth extends HeyApiClient { public set( parameters: { providerID: string - auth?: Auth3 + body?: OAuth | ApiAuth | WellKnownAuth }, options?: Options, ) { @@ -347,7 +349,7 @@ export class Auth extends HeyApiClient { { args: [ { in: "path", key: "providerID" }, - { key: "auth", map: "body" }, + { key: "body", map: "body" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ec797f2ba8..5938693260 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -47,13 +47,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -61,121 +54,6 @@ export type EventServerInstanceDisposed = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -205,6 +83,13 @@ export type EventLspUpdated = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type OutputFormatText = { type: "text" } @@ -664,6 +549,35 @@ export type EventMessagePartRemoved = { } } +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + export type SessionStatus = | { type: "idle" @@ -693,6 +607,77 @@ export type EventSessionIdle = { } } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -700,6 +685,14 @@ export type EventSessionCompacted = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type Todo = { /** * Brief description of the task @@ -889,6 +882,13 @@ export type EventSessionError = { } } +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type EventWorkspaceReady = { type: "workspace.ready" properties: { @@ -961,27 +961,26 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated - | EventFileEdited | EventServerInstanceDisposed - | EventFileWatcherUpdated - | EventPermissionAsked - | EventPermissionReplied - | EventVcsBranchUpdated - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated + | EventFileEdited | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartDelta | EventMessagePartRemoved + | EventPermissionAsked + | EventPermissionReplied | EventSessionStatus | EventSessionIdle + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventSessionCompacted + | EventFileWatcherUpdated | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute @@ -995,6 +994,7 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError + | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventPtyCreated @@ -1534,8 +1534,6 @@ export type WellKnownAuth = { token: string } -export type Auth = OAuth | ApiAuth | WellKnownAuth - export type NotFoundError = { name: "NotFoundError" data: { @@ -2058,7 +2056,7 @@ export type AuthRemoveResponses = { export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] export type AuthSetData = { - body?: Auth + body?: OAuth | ApiAuth | WellKnownAuth path: { providerID: string } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fa894045e1..76eed0025b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -199,7 +199,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Auth" + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" + }, + { + "$ref": "#/components/schemas/ApiAuth" + }, + { + "$ref": "#/components/schemas/WellKnownAuth" + } + ] } } } @@ -7053,25 +7063,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, "Event.server.instance.disposed": { "type": "object", "properties": { @@ -7091,299 +7082,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "permission": { - "type": "string" - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["type", "properties"] - }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"] - } - }, - "required": ["type", "properties"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -7448,6 +7146,25 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, "OutputFormatText": { "type": "object", "properties": { @@ -8823,6 +8540,96 @@ }, "required": ["type", "properties"] }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^per.*" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, "SessionStatus": { "anyOf": [ { @@ -8909,6 +8716,156 @@ }, "required": ["type", "properties"] }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, "Event.session.compacted": { "type": "object", "properties": { @@ -8929,6 +8886,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Todo": { "type": "object", "properties": { @@ -9415,6 +9407,24 @@ }, "required": ["type", "properties"] }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, "Event.workspace.ready": { "type": "object", "properties": { @@ -9618,33 +9628,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9657,6 +9643,9 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, { "$ref": "#/components/schemas/Event.message.updated" }, @@ -9672,15 +9661,33 @@ { "$ref": "#/components/schemas/Event.message.part.removed" }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, { "$ref": "#/components/schemas/Event.session.status" }, { "$ref": "#/components/schemas/Event.session.idle" }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, { "$ref": "#/components/schemas/Event.session.compacted" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.todo.updated" }, @@ -9720,6 +9727,9 @@ { "$ref": "#/components/schemas/Event.session.error" }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, { "$ref": "#/components/schemas/Event.workspace.ready" }, @@ -10824,19 +10834,6 @@ }, "required": ["type", "key", "token"] }, - "Auth": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" - }, - { - "$ref": "#/components/schemas/ApiAuth" - }, - { - "$ref": "#/components/schemas/WellKnownAuth" - } - ] - }, "NotFoundError": { "type": "object", "properties": { From 9b805e1cc4ba4a98419ca13d9d487c4550af8ddf Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 21 Mar 2026 04:07:51 -0400 Subject: [PATCH 017/108] wip: zen --- .../app/src/routes/zen/util/provider/anthropic.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 2b617bff62..b63be8688a 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:") const isBedrockModelID = providerModel.startsWith("global.anthropic.") const isBedrock = isBedrockModelArn || isBedrockModelID + const isDatabricks = providerModel.startsWith("databricks-claude-") const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6") return { format: "anthropic", @@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => ? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}` : providerApi + "/messages", modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { - if (isBedrock) { + if (isBedrock || isDatabricks) { headers.set("Authorization", `Bearer ${apiKey}`) } else { headers.set("x-api-key", apiKey) @@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => model: undefined, stream: undefined, } - : { - service_tier: "standard_only", - }), + : isDatabricks + ? { + anthropic_version: "bedrock-2023-05-31", + anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined, + } + : { + service_tier: "standard_only", + }), }), createBinaryStreamDecoder: () => { if (!isBedrock) return undefined From f80343b875124cc96807b47055ecb2e28c6d3ebf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 21 Mar 2026 09:11:15 -0400 Subject: [PATCH 018/108] fix annotation --- packages/opencode/src/auth/index.ts | 2 +- packages/opencode/src/util/effect-zod.ts | 6 ++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +++----- packages/sdk/js/src/v2/gen/types.gen.ts | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index c50040f1d7..2238d57f5d 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -32,7 +32,7 @@ export namespace Auth { token: Schema.String, }) {} - const _Info = Schema.Union([Oauth, Api, WellKnown]) + const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) export const Info = Object.assign(_Info, { zod: zod(_Info) }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index c1407594ca..97cbbd2fc9 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -60,6 +60,12 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny { const items = ast.types.map(walk) if (items.length === 1) return items[0] if (items.length < 2) return fail(ast) + + const discriminator = (ast as any).annotations?.discriminator + if (discriminator) { + return z.discriminatedUnion(discriminator, items as [z.ZodObject, z.ZodObject, ...z.ZodObject[]]) + } + return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index df46c66f20..b6821322e2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,11 +4,11 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, - ApiAuth, AppAgentsResponses, AppLogErrors, AppLogResponses, AppSkillsResponses, + Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, @@ -63,7 +63,6 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, - OAuth, OutputFormat, Part as Part2, PartDeleteErrors, @@ -174,7 +173,6 @@ import type { TuiShowToastResponses, TuiSubmitPromptResponses, VcsGetResponses, - WellKnownAuth, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -339,7 +337,7 @@ export class Auth extends HeyApiClient { public set( parameters: { providerID: string - body?: OAuth | ApiAuth | WellKnownAuth + auth?: Auth3 }, options?: Options, ) { @@ -349,7 +347,7 @@ export class Auth extends HeyApiClient { { args: [ { in: "path", key: "providerID" }, - { key: "body", map: "body" }, + { key: "auth", map: "body" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5938693260..f7aab687e6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1534,6 +1534,8 @@ export type WellKnownAuth = { token: string } +export type Auth = OAuth | ApiAuth | WellKnownAuth + export type NotFoundError = { name: "NotFoundError" data: { @@ -2056,7 +2058,7 @@ export type AuthRemoveResponses = { export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] export type AuthSetData = { - body?: OAuth | ApiAuth | WellKnownAuth + body?: Auth path: { providerID: string } From fb6bf0b35e623113d23d15cc1a12b28fedd31f88 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 21 Mar 2026 13:12:54 +0000 Subject: [PATCH 019/108] chore: generate --- packages/sdk/openapi.json | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 76eed0025b..9f3a69c54c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -199,17 +199,7 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" - }, - { - "$ref": "#/components/schemas/ApiAuth" - }, - { - "$ref": "#/components/schemas/WellKnownAuth" - } - ] + "$ref": "#/components/schemas/Auth" } } } @@ -10834,6 +10824,19 @@ }, "required": ["type", "key", "token"] }, + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" + }, + { + "$ref": "#/components/schemas/ApiAuth" + }, + { + "$ref": "#/components/schemas/WellKnownAuth" + } + ] + }, "NotFoundError": { "type": "object", "properties": { From 9ad6588f3e0066125033810a5e0e4dc08f0c6961 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 21 Mar 2026 22:13:09 +0800 Subject: [PATCH 020/108] app: allow navigating projects with keybinds (#18502) --- packages/app/src/i18n/en.ts | 2 ++ packages/app/src/pages/layout.tsx | 34 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad..8efd9d3bc9 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,8 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", + "command.project.previous": "Previous project", + "command.project.next": "Next project", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8e2248469d..2adcd3b563 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -936,6 +936,26 @@ export default function Layout(props: ParentProps) { navigateToSession(session) } + function navigateProjectByOffset(offset: number) { + const projects = layout.projects.list() + if (projects.length === 0) return + + const current = currentProject()?.worktree + const fallback = currentDir() ? projectRoot(currentDir()) : undefined + const active = current ?? fallback + const index = active ? projects.findIndex((project) => project.worktree === active) : -1 + + const target = + index === -1 + ? offset > 0 + ? projects[0] + : projects[projects.length - 1] + : projects[(index + offset + projects.length) % projects.length] + if (!target) return + + openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1002,6 +1022,20 @@ export default function Layout(props: ParentProps) { keybind: "mod+o", onSelect: () => chooseProject(), }, + { + id: "project.previous", + title: language.t("command.project.previous"), + category: language.t("command.category.project"), + keybind: "mod+alt+arrowup", + onSelect: () => navigateProjectByOffset(-1), + }, + { + id: "project.next", + title: language.t("command.project.next"), + category: language.t("command.category.project"), + keybind: "mod+alt+arrowdown", + onSelect: () => navigateProjectByOffset(1), + }, { id: "provider.connect", title: language.t("command.provider.connect"), From 6a16db4b929422b6f5ef7072ac889cec41ae1eb2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 21 Mar 2026 23:33:04 +0800 Subject: [PATCH 021/108] app: manage mutation loading states with tanstack query (#18501) --- bun.lock | 5 + packages/app/package.json | 1 + packages/app/src/app.tsx | 18 +- .../components/dialog-connect-provider.tsx | 3 +- .../components/dialog-custom-provider-form.ts | 1 - .../components/dialog-custom-provider.test.ts | 2 - .../src/components/dialog-custom-provider.tsx | 77 +++--- .../src/components/dialog-edit-project.tsx | 59 ++--- .../app/src/components/dialog-select-mcp.tsx | 30 ++- .../src/components/dialog-select-server.tsx | 173 ++++++------ .../app/src/components/status-popover.tsx | 58 ++-- packages/app/src/pages/session.tsx | 250 +++++++++--------- .../composer/session-question-dock.tsx | 84 +++--- .../src/pages/session/message-timeline.tsx | 124 +++++---- 14 files changed, 453 insertions(+), 432 deletions(-) diff --git a/bun.lock b/bun.lock index 58cfe892f3..7e07e61ddf 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@tanstack/solid-query": "5.91.4", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", @@ -1966,10 +1967,14 @@ "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], + "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], diff --git a/packages/app/package.json b/packages/app/package.json index 545d313098..3f4e2472f2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -54,6 +54,7 @@ "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@tanstack/solid-query": "5.91.4", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9a282bbb70..5247c951d3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { type Duration, Effect } from "effect" import { type Component, @@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) { return {props.children} } +function QueryProvider(props: ParentProps) { + const client = new QueryClient() + return {props.children} +} + function AppShellProviders(props: ParentProps) { return ( @@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) { }> - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index e4fe9e7c4e..734958dd58 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" -import { useLanguage } from "@/context/language" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" -import { DialogSelectModel } from "./dialog-select-model" +import { useLanguage } from "@/context/language" import { DialogSelectProvider } from "./dialog-select-provider" export function DialogConnectProvider(props: { provider: string }) { diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index 92d235c3bc..e26dcb0971 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -34,7 +34,6 @@ export type FormState = { apiKey: string models: ModelRow[] headers: HeaderRow[] - saving: boolean err: { providerID?: string name?: string diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 8cfd78ebeb..07dd26ecd6 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -16,7 +16,6 @@ describe("validateCustomProvider", () => { { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h1", key: "", value: "", err: {} }, ], - saving: false, err: {}, }, t, @@ -60,7 +59,6 @@ describe("validateCustomProvider", () => { { row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} }, ], - saving: false, err: {}, }, t, diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 4d220a0b19..53b66fb451 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { batch, For } from "solid-js" @@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) { apiKey: "", models: [modelRow()], headers: [headerRow()], - saving: false, err: {}, }) @@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) { return output.result } - const save = async (e: SubmitEvent) => { - e.preventDefault() - if (form.saving) return + const saveMutation = useMutation(() => ({ + mutationFn: async (result: NonNullable>) => { + const disabledProviders = globalSync.data.config.disabled_providers ?? [] + const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - const result = validate() - if (!result) return - - setForm("saving", true) - - const disabledProviders = globalSync.data.config.disabled_providers ?? [] - const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - - const auth = result.key - ? globalSDK.client.auth.set({ + if (result.key) { + await globalSDK.client.auth.set({ providerID: result.providerID, auth: { type: "api", key: result.key, }, }) - : Promise.resolve() + } - auth - .then(() => - globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), - ) - .then(() => { - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: result.name }), - description: language.t("provider.connect.toast.connected.description", { provider: result.name }), - }) + await globalSync.updateConfig({ + provider: { [result.providerID]: result.config }, + disabled_providers: nextDisabled, }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => { - setForm("saving", false) + return result + }, + onSuccess: (result) => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: result.name }), + description: language.t("provider.connect.toast.connected.description", { provider: result.name }), }) + }, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }, + })) + + const save = (e: SubmitEvent) => { + e.preventDefault() + if (saveMutation.isPending) return + + const result = validate() + if (!result) return + saveMutation.mutate(result) } return ( @@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) { - diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ec0793c540..eb962f47eb 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" @@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) { color: props.project.icon?.color || "pink", iconUrl: props.project.icon?.override || "", startup: props.project.commands?.start ?? "", - saving: false, dragOver: false, iconHover: false, }) @@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) { setStore("iconUrl", "") } - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() + const saveMutation = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() - await Promise.resolve() - .then(async () => { - setStore("saving", true) - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await globalSDK.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color, override: store.iconUrl }, - commands: { start }, - }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) - dialog.close() - return - } - - globalSync.project.meta(props.project.worktree, { + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl || undefined }, - commands: { start: start || undefined }, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, }) - .finally(() => { - setStore("saving", false) - }) + dialog.close() + }, + })) + + function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (saveMutation.isPending) return + saveMutation.mutate() } return ( @@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) { - diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index f8913eee4f..fafba6168c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,4 +1,5 @@ -import { Component, createMemo, createSignal, Show } from "solid-js" +import { useMutation } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" @@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() - const [loading, setLoading] = createSignal(null) const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => { .sort((a, b) => a.name.localeCompare(b.name)), ) - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) - try { + const toggle = useMutation(() => ({ + mutationFn: async (name: string) => { const status = sync.data.mcp[name] if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name }) @@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => { const result = await sdk.client.mcp.status() if (result.data) sync.set("mcp", result.data) - } finally { - setLoading(null) - } - } + }, + })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const totalCount = createMemo(() => items().length) @@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (x) toggle(x.name) + if (!x || toggle.isPending) return + toggle.mutate(x.name) }} > {(i) => { @@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => { {statusLabel()} - + {language.t("common.loading.ellipsis")} @@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> - toggle(i.name)} /> + { + if (toggle.isPending) return + toggle.mutate(i.name) + }} + />
) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index f8d14cbb94..ca4c42a376 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" @@ -186,7 +187,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined as boolean | undefined, @@ -198,7 +198,6 @@ export function DialogSelectServer() { username: "", password: "", error: "", - busy: false, status: undefined as boolean | undefined, }, }) @@ -209,7 +208,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined, @@ -224,10 +222,78 @@ export function DialogSelectServer() { password: "", error: "", status: undefined, - busy: false, }) } + const addMutation = useMutation(() => ({ + mutationFn: async (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } + + const conn: ServerConnection.Http = { + type: "http", + http: { url: normalized }, + } + if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() + if (store.addServer.password) conn.http.password = store.addServer.password + if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + + resetAdd() + await select(conn, true) + }, + })) + + const editMutation = useMutation(() => ({ + mutationFn: async (input: { original: ServerConnection.Any; value: string }) => { + if (input.original.type !== "http") return + const normalized = normalizeServerUrl(input.value) + if (!normalized) { + resetEdit() + return + } + + const name = store.editServer.name.trim() || undefined + const username = store.editServer.username || undefined + const password = store.editServer.password || undefined + const existingName = input.original.displayName + if ( + normalized === input.original.http.url && + name === existingName && + username === input.original.http.username && + password === input.original.http.password + ) { + resetEdit() + return + } + + const conn: ServerConnection.Http = { + type: "http", + displayName: name, + http: { url: normalized, username, password }, + } + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + if (normalized === input.original.http.url) { + server.add(conn) + } else { + replaceServer(input.original, conn) + } + + resetEdit() + }, + })) + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const active = server.key const newConn = server.add(next) @@ -296,7 +362,7 @@ export function DialogSelectServer() { } const handleAddChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) void previewStatus(value, store.addServer.username, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -304,12 +370,12 @@ export function DialogSelectServer() { } const handleAddNameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { name: value, error: "" }) } const handleAddUsernameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { username: value, error: "" }) void previewStatus(store.addServer.url, value, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -317,7 +383,7 @@ export function DialogSelectServer() { } const handleAddPasswordChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { password: value, error: "" }) void previewStatus(store.addServer.url, store.addServer.username, value, (next) => setStore("addServer", { status: next }), @@ -325,7 +391,7 @@ export function DialogSelectServer() { } const handleEditChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { value, error: "" }) void previewStatus(value, store.editServer.username, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -333,12 +399,12 @@ export function DialogSelectServer() { } const handleEditNameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { name: value, error: "" }) } const handleEditUsernameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { username: value, error: "" }) void previewStatus(store.editServer.value, value, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -346,85 +412,13 @@ export function DialogSelectServer() { } const handleEditPasswordChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { password: value, error: "" }) void previewStatus(store.editServer.value, store.editServer.username, value, (next) => setStore("editServer", { status: next }), ) } - async function handleAdd(value: string) { - if (store.addServer.adding) return - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetAdd() - return - } - - setStore("addServer", { adding: true, error: "" }) - - const conn: ServerConnection.Http = { - type: "http", - http: { url: normalized }, - } - if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() - if (store.addServer.password) conn.http.password = store.addServer.password - if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username - const result = await checkServerHealth(conn.http) - setStore("addServer", { adding: false }) - if (!result.healthy) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - - resetAdd() - await select(conn, true) - } - - async function handleEdit(original: ServerConnection.Any, value: string) { - if (store.editServer.busy || original.type !== "http") return - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetEdit() - return - } - - const name = store.editServer.name.trim() || undefined - const username = store.editServer.username || undefined - const password = store.editServer.password || undefined - const existingName = original.displayName - if ( - normalized === original.http.url && - name === existingName && - username === original.http.username && - password === original.http.password - ) { - resetEdit() - return - } - - setStore("editServer", { busy: true, error: "" }) - - const conn: ServerConnection.Http = { - type: "http", - displayName: name, - http: { url: normalized, username, password }, - } - const result = await checkServerHealth(conn.http) - setStore("editServer", { busy: false }) - if (!result.healthy) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if (normalized === original.http.url) { - server.add(conn) - } else { - replaceServer(original, conn) - } - - resetEdit() - } - const mode = createMemo<"list" | "add" | "edit">(() => { if (store.editServer.id) return "edit" if (store.addServer.showForm) return "add" @@ -464,23 +458,26 @@ export function DialogSelectServer() { password: conn.http.password ?? "", error: "", status: store.status[ServerConnection.key(conn)]?.healthy, - busy: false, }) } const submitForm = () => { if (mode() === "add") { - void handleAdd(store.addServer.url) + if (addMutation.isPending) return + setStore("addServer", { error: "" }) + addMutation.mutate(store.addServer.url) return } const original = editing() if (!original) return - void handleEdit(original, store.editServer.value) + if (editMutation.isPending) return + setStore("editServer", { error: "" }) + editMutation.mutate({ original, value: store.editServer.value }) } const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") - const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) + const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formTitle = createMemo(() => { if (!isFormMode()) return language.t("dialog.server.title") diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 063205f0c3..464522443f 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Popover } from "@opencode-ai/ui/popover" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" +import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" @@ -130,41 +131,30 @@ const useDefaultServerKey = ( } } -const useMcpToggle = (input: { - sync: ReturnType - sdk: ReturnType - language: ReturnType -}) => { - const [loading, setLoading] = createSignal(null) +const useMcpToggleMutation = () => { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) - - try { - const status = input.sync.data.mcp[name] - await (status?.status === "connected" - ? input.sdk.client.mcp.disconnect({ name }) - : input.sdk.client.mcp.connect({ name })) - const result = await input.sdk.client.mcp.status() - if (result.data) input.sync.set("mcp", result.data) - } catch (err) { + return useMutation(() => ({ + mutationFn: async (name: string) => { + const status = sync.data.mcp[name] + await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + }, + onError: (err) => { showToast({ variant: "error", - title: input.language.t("common.requestFailed"), + title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : String(err), }) - } finally { - setLoading(null) - } - } - - return { loading, toggle } + }, + })) } export function StatusPopover() { const sync = useSync() - const sdk = useSDK() const server = useServer() const platform = usePlatform() const dialog = useDialog() @@ -181,7 +171,7 @@ export function StatusPopover() { }) const health = useServerHealth(servers) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) - const mcp = useMcpToggle({ sync, sdk, language }) + const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status @@ -337,8 +327,11 @@ export function StatusPopover() { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081..428826f6ad 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,5 +1,6 @@ import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" import { batch, onCleanup, @@ -327,10 +328,7 @@ export default function Page() { }) const [ui, setUi] = createStore({ - git: false, pendingMessage: undefined as string | undefined, - restoring: undefined as string | undefined, - reverting: false, reviewSnap: false, scrollGesture: 0, scroll: { @@ -506,7 +504,6 @@ export default function Page() { const [followup, setFollowup] = createStore({ items: {} as Record, - sending: {} as Record, failed: {} as Record, paused: {} as Record, edit: {} as Record< @@ -644,25 +641,24 @@ export default function Page() { globalSync.set("project", [...list, next]) } + const gitMutation = useMutation(() => ({ + mutationFn: () => sdk.client.project.initGit(), + onSuccess: (x) => { + if (!x.data) return + upsert(x.data) + }, + onError: (err) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: formatServerError(err, language.t), + }) + }, + })) + function initGit() { - if (ui.git) return - setUi("git", true) - void sdk.client.project - .initGit() - .then((x) => { - if (!x.data) return - upsert(x.data) - }) - .catch((err) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - }) - .finally(() => { - setUi("git", false) - }) + if (gitMutation.isPending) return + gitMutation.mutate() } let inputRef!: HTMLDivElement @@ -961,8 +957,8 @@ export default function Page() { {language.t("session.review.noVcs.createGit.description")} - @@ -1379,10 +1375,40 @@ export default function Page() { return followup.edit[id] }) + const followupMutation = useMutation(() => ({ + mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) + if (!item) return + + if (input.manual) setFollowup("paused", input.sessionID, undefined) + setFollowup("failed", input.sessionID, undefined) + + const ok = await sendFollowupDraft({ + client: sdk.client, + sync, + globalSync, + draft: item, + optimisticBusy: item.sessionDirectory === sdk.directory, + }).catch((err) => { + setFollowup("failed", input.sessionID, input.id) + fail(err) + return false + }) + if (!ok) return + + setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) + if (input.manual) resumeScroll() + }, + })) + + const followupBusy = (sessionID: string) => + followupMutation.isPending && followupMutation.variables?.sessionID === sessionID + const sendingFollowup = createMemo(() => { const id = params.id if (!id) return - return followup.sending[id] + if (!followupBusy(id)) return + return followupMutation.variables?.id }) const queueEnabled = createMemo(() => { @@ -1422,37 +1448,15 @@ export default function Page() { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() - if (followup.sending[sessionID]) return Promise.resolve() + if (followupBusy(sessionID)) return Promise.resolve() - if (opts?.manual) setFollowup("paused", sessionID, undefined) - setFollowup("sending", sessionID, id) - setFollowup("failed", sessionID, undefined) - - return sendFollowupDraft({ - client: sdk.client, - sync, - globalSync, - draft: item, - optimisticBusy: item.sessionDirectory === sdk.directory, - }) - .then((ok) => { - if (ok === false) return - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) - if (opts?.manual) resumeScroll() - }) - .catch((err) => { - setFollowup("failed", sessionID, id) - fail(err) - }) - .finally(() => { - setFollowup("sending", sessionID, (value) => (value === id ? undefined : value)) - }) + return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) } const editFollowup = (id: string) => { const sessionID = params.id if (!sessionID) return - if (followup.sending[sessionID]) return + if (followupBusy(sessionID)) return const item = queuedFollowups().find((entry) => entry.id === id) if (!item) return @@ -1475,6 +1479,74 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() + const revertMutation = useMutation(() => ({ + mutationFn: async (input: { sessionID: string; messageID: string }) => { + const prev = prompt.current().slice() + const last = info()?.revert + const value = draft(input.messageID) + batch(() => { + roll(input.sessionID, { messageID: input.messageID }) + prompt.set(value) + }) + await halt(input.sessionID) + .then(() => sdk.client.session.revert(input)) + .then((result) => { + if (result.data) merge(result.data) + }) + .catch((err) => { + batch(() => { + roll(input.sessionID, last) + prompt.set(prev) + }) + fail(err) + }) + }, + })) + + const restoreMutation = useMutation(() => ({ + mutationFn: async (id: string) => { + const sessionID = params.id + if (!sessionID) return + + const next = userMessages().find((item) => item.id > id) + const prev = prompt.current().slice() + const last = info()?.revert + + batch(() => { + roll(sessionID, next ? { messageID: next.id } : undefined) + if (next) { + prompt.set(draft(next.id)) + return + } + prompt.reset() + }) + + const task = !next + ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => + sdk.client.session.revert({ + sessionID, + messageID: next.id, + }), + ) + + await task + .then((result) => { + if (result.data) merge(result.data) + }) + .catch((err) => { + batch(() => { + roll(sessionID, last) + prompt.set(prev) + }) + fail(err) + }) + }, + })) + + const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) + const fork = (input: { sessionID: string; messageID: string }) => { const value = draft(input.messageID) const dir = base64Encode(sdk.directory) @@ -1496,77 +1568,13 @@ export default function Page() { } const revert = (input: { sessionID: string; messageID: string }) => { - if (ui.reverting || ui.restoring) return - const prev = prompt.current().slice() - const last = info()?.revert - const value = draft(input.messageID) - batch(() => { - setUi("reverting", true) - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) - }) - return halt(input.sessionID) - .then(() => sdk.client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - .finally(() => { - setUi("reverting", false) - }) + if (reverting()) return + return revertMutation.mutateAsync(input) } const restore = (id: string) => { - const sessionID = params.id - if (!sessionID || ui.restoring || ui.reverting) return - - const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert - - batch(() => { - setUi("restoring", id) - setUi("reverting", true) - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() - }) - - const task = !next - ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk.client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - return task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - .finally(() => { - batch(() => { - setUi("restoring", (value) => (value === id ? undefined : value)) - setUi("reverting", false) - }) - }) + if (!params.id || reverting()) return + return restoreMutation.mutateAsync(id) } const rolled = createMemo(() => { @@ -1585,7 +1593,7 @@ export default function Page() { const item = queuedFollowups()[0] if (!item) return - if (followup.sending[sessionID]) return + if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return if (followup.paused[sessionID]) return if (composer.blocked()) return @@ -1780,8 +1788,8 @@ export default function Page() { rolled().length > 0 ? { items: rolled(), - restoring: ui.restoring, - disabled: ui.reverting, + restoring: restoring(), + disabled: reverting(), onRestore: restore, } : undefined diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b66c27579a..7ba07b15d0 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" +import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" @@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit custom: cached?.custom ?? ([] as string[]), customOn: cached?.customOn ?? ([] as boolean[]), editing: false, - sending: false, }) let root: HTMLDivElement | undefined @@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit showToast({ title: language.t("common.requestFailed"), description: message }) } - const reply = async (answers: QuestionAnswer[]) => { - if (store.sending) return - - props.onSubmit() - setStore("sending", true) - try { - await sdk.client.question.reply({ requestID: props.request.id, answers }) + const replyMutation = useMutation(() => ({ + mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }), + onMutate: () => { + props.onSubmit() + }, + onSuccess: () => { replied = true cache.delete(props.request.id) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } + }, + onError: fail, + })) + + const rejectMutation = useMutation(() => ({ + mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }), + onMutate: () => { + props.onSubmit() + }, + onSuccess: () => { + replied = true + cache.delete(props.request.id) + }, + onError: fail, + })) + + const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) + + const reply = async (answers: QuestionAnswer[]) => { + if (sending()) return + await replyMutation.mutateAsync(answers) } const reject = async () => { - if (store.sending) return - - props.onSubmit() - setStore("sending", true) - try { - await sdk.client.question.reject({ requestID: props.request.id }) - replied = true - cache.delete(props.request.id) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } + if (sending()) return + await rejectMutation.mutateAsync() } const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) @@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const customToggle = () => { - if (store.sending) return + if (sending()) return if (!multi()) { setStore("customOn", store.tab, true) @@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const customOpen = () => { - if (store.sending) return + if (sending()) return if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) } const selectOption = (optIndex: number) => { - if (store.sending) return + if (sending()) return if (optIndex === options().length) { customOpen() @@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const next = () => { - if (store.sending) return + if (sending()) return if (store.editing) commitCustom() if (store.tab >= total() - 1) { @@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const back = () => { - if (store.sending) return + if (sending()) return if (store.tab <= 0) return setStore("tab", store.tab - 1) setStore("editing", false) } const jump = (tab: number) => { - if (store.sending) return + if (sending()) return setStore("tab", tab) setStore("editing", false) } @@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit (store.answers[i()]?.length ?? 0) > 0 || (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) } - disabled={store.sending} + disabled={sending()} onClick={() => jump(i())} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} /> @@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } footer={ <> -
0}> - -
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit data-picked={picked()} role={multi() ? "checkbox" : "radio"} aria-checked={picked()} - disabled={store.sending} + disabled={sending()} onClick={() => selectOption(i())} >
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { + demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { withAlpha } from "@opencode-ai/ui/theme/color" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..c795ab471c 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -33,27 +33,11 @@ type GlobalStore = { export async function bootstrapGlobal(input: { globalSDK: OpencodeClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - const tasks = [ retry(() => input.globalSDK.path.get().then((x) => { @@ -80,11 +64,6 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("provider", normalizeProviderList(x.data!)) }), ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), ] const results = await Promise.allSettled(tasks) @@ -111,6 +90,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -119,88 +102,112 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string + global: { + config: Config + project: Project[] + provider: ProviderListResponse + } }) { - if (input.store.status !== "complete") input.setStore("status", "loading") + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + input.setStore("provider", input.global.provider) + } + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", input.global.config) + } + if (loading) input.setStore("status", "partial") - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => + const results = await Promise.allSettled([ + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + retry(() => input.sdk.provider.list().then((x) => { input.setStore("provider", normalizeProviderList(x.data!)) }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), - } + ), + retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + input.loadSessions(input.directory), + retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ]) - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const errors = results + .filter((item): item is PromiseRejectedResult => item.status === "rejected") + .map((item) => item.reason) + if (errors.length > 0) { + console.error("Failed to bootstrap instance", errors[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errors[0], input.translate), }) - input.setStore("status", "partial") return } - if (input.store.status !== "complete") input.setStore("status", "partial") - - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading) input.setStore("status", "complete") } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..247d36dd36 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,6 +118,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return + void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) }) diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75..da22c55523 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } +const localUrl = () => + `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + +const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + if (location.hostname.includes("opencode.ai")) return localUrl() + if (import.meta.env.DEV) return localUrl() + if (isLocalHost()) return localUrl() return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d8b0732580..d01c7d3ceb 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1152,10 +1150,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1206,15 +1204,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1441,7 +1451,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1462,10 +1478,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 45a323c7be..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,6 +14,15 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -87,5 +96,18 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 6dea812ec8..78e5a0c565 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,106 +1,89 @@ -import alert01 from "@opencode-ai/ui/audio/alert-01.aac" -import alert02 from "@opencode-ai/ui/audio/alert-02.aac" -import alert03 from "@opencode-ai/ui/audio/alert-03.aac" -import alert04 from "@opencode-ai/ui/audio/alert-04.aac" -import alert05 from "@opencode-ai/ui/audio/alert-05.aac" -import alert06 from "@opencode-ai/ui/audio/alert-06.aac" -import alert07 from "@opencode-ai/ui/audio/alert-07.aac" -import alert08 from "@opencode-ai/ui/audio/alert-08.aac" -import alert09 from "@opencode-ai/ui/audio/alert-09.aac" -import alert10 from "@opencode-ai/ui/audio/alert-10.aac" -import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" -import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" -import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" -import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" -import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" -import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" -import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" -import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" -import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" -import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" -import nope01 from "@opencode-ai/ui/audio/nope-01.aac" -import nope02 from "@opencode-ai/ui/audio/nope-02.aac" -import nope03 from "@opencode-ai/ui/audio/nope-03.aac" -import nope04 from "@opencode-ai/ui/audio/nope-04.aac" -import nope05 from "@opencode-ai/ui/audio/nope-05.aac" -import nope06 from "@opencode-ai/ui/audio/nope-06.aac" -import nope07 from "@opencode-ai/ui/audio/nope-07.aac" -import nope08 from "@opencode-ai/ui/audio/nope-08.aac" -import nope09 from "@opencode-ai/ui/audio/nope-09.aac" -import nope10 from "@opencode-ai/ui/audio/nope-10.aac" -import nope11 from "@opencode-ai/ui/audio/nope-11.aac" -import nope12 from "@opencode-ai/ui/audio/nope-12.aac" -import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" -import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" -import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" -import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" -import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" -import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" -import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" -import yup01 from "@opencode-ai/ui/audio/yup-01.aac" -import yup02 from "@opencode-ai/ui/audio/yup-02.aac" -import yup03 from "@opencode-ai/ui/audio/yup-03.aac" -import yup04 from "@opencode-ai/ui/audio/yup-04.aac" -import yup05 from "@opencode-ai/ui/audio/yup-05.aac" -import yup06 from "@opencode-ai/ui/audio/yup-06.aac" +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01", src: alert01 }, - { id: "alert-02", label: "sound.option.alert02", src: alert02 }, - { id: "alert-03", label: "sound.option.alert03", src: alert03 }, - { id: "alert-04", label: "sound.option.alert04", src: alert04 }, - { id: "alert-05", label: "sound.option.alert05", src: alert05 }, - { id: "alert-06", label: "sound.option.alert06", src: alert06 }, - { id: "alert-07", label: "sound.option.alert07", src: alert07 }, - { id: "alert-08", label: "sound.option.alert08", src: alert08 }, - { id: "alert-09", label: "sound.option.alert09", src: alert09 }, - { id: "alert-10", label: "sound.option.alert10", src: alert10 }, - { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, - { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, - { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, - { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, - { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, - { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, - { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, - { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, - { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, - { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, - { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, - { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, - { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, - { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, - { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, - { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, - { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, - { id: "nope-01", label: "sound.option.nope01", src: nope01 }, - { id: "nope-02", label: "sound.option.nope02", src: nope02 }, - { id: "nope-03", label: "sound.option.nope03", src: nope03 }, - { id: "nope-04", label: "sound.option.nope04", src: nope04 }, - { id: "nope-05", label: "sound.option.nope05", src: nope05 }, - { id: "nope-06", label: "sound.option.nope06", src: nope06 }, - { id: "nope-07", label: "sound.option.nope07", src: nope07 }, - { id: "nope-08", label: "sound.option.nope08", src: nope08 }, - { id: "nope-09", label: "sound.option.nope09", src: nope09 }, - { id: "nope-10", label: "sound.option.nope10", src: nope10 }, - { id: "nope-11", label: "sound.option.nope11", src: nope11 }, - { id: "nope-12", label: "sound.option.nope12", src: nope12 }, - { id: "yup-01", label: "sound.option.yup01", src: yup01 }, - { id: "yup-02", label: "sound.option.yup02", src: yup02 }, - { id: "yup-03", label: "sound.option.yup03", src: yup03 }, - { id: "yup-04", label: "sound.option.yup04", src: yup04 }, - { id: "yup-05", label: "sound.option.yup05", src: yup05 }, - { id: "yup-06", label: "sound.option.yup06", src: yup06 }, + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() export function soundSrc(id: string | undefined) { - if (!id) return - if (!(id in soundById)) return - return soundById[id as SoundID] + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next } export function playSound(src: string | undefined) { @@ -108,10 +91,12 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) - - // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index ec2b4d1e7a..44f2e6360c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -246,6 +249,17 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -257,6 +271,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -309,15 +324,14 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index e677956440..5fe88d501b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -414,6 +417,17 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -423,6 +437,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -465,8 +480,8 @@ render(() => { return ( - - + + {(_) => { return ( + + diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg new file mode 100644 index 0000000000..b3a2edc3c0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg new file mode 100644 index 0000000000..086e9aa1fc --- /dev/null +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg new file mode 100644 index 0000000000..e045c96fb3 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg new file mode 100644 index 0000000000..472dee9122 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg new file mode 100644 index 0000000000..a0f38862a4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg new file mode 100644 index 0000000000..502e51a5be --- /dev/null +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index d8d9ef665f..9eb8045e45 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index f735747a49..e1a508f16a 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,121 +1,9 @@ +import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" -import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" - -import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" -import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" -import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" -import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" -import hack from "../assets/fonts/hack-nerd-font.woff2" -import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" -import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" -import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" -import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" -import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" -import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" -import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" -import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" -import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" -import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" -import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" -import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" -import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" -import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" -import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" -import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" -import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" -import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" -import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" - -type MonoFont = { - family: string - regular: string - bold: string -} - -export const MONO_NERD_FONTS = [ - { - family: "JetBrains Mono Nerd Font", - regular: jetbrainsMono, - bold: jetbrainsMonoBold, - }, - { - family: "Fira Code Nerd Font", - regular: firaCode, - bold: firaCodeBold, - }, - { - family: "Cascadia Code Nerd Font", - regular: cascadiaCode, - bold: cascadiaCodeBold, - }, - { - family: "Hack Nerd Font", - regular: hack, - bold: hackBold, - }, - { - family: "Source Code Pro Nerd Font", - regular: sourceCodePro, - bold: sourceCodeProBold, - }, - { - family: "Inconsolata Nerd Font", - regular: inconsolata, - bold: inconsolataBold, - }, - { - family: "Roboto Mono Nerd Font", - regular: robotoMono, - bold: robotoMonoBold, - }, - { - family: "Ubuntu Mono Nerd Font", - regular: ubuntuMono, - bold: ubuntuMonoBold, - }, - { - family: "Intel One Mono Nerd Font", - regular: intelOneMono, - bold: intelOneMonoBold, - }, - { - family: "Meslo LGS Nerd Font", - regular: mesloLgs, - bold: mesloLgsBold, - }, - { - family: "Iosevka Nerd Font", - regular: iosevka, - bold: iosevkaBold, - }, - { - family: "GeistMono Nerd Font", - regular: geistMono, - bold: geistMonoBold, - }, -] satisfies MonoFont[] - -const monoNerdCss = MONO_NERD_FONTS.map( - (font) => ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - }`, -).join("") +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" export const Font = () => { return ( @@ -165,7 +53,6 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } -${monoNerdCss} `} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - window.addEventListener("storage", (e) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, From 41c77ccb33b26c09aca2ab96661dc31a5db70264 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 10:35:24 -0400 Subject: [PATCH 078/108] fix: restore cross-spawn behavior for effect child processes (#18798) --- .../src/effect/cross-spawn-spawner.ts | 476 ++++++++++++++++ packages/opencode/src/installation/index.ts | 5 +- packages/opencode/src/snapshot/index.ts | 7 +- .../test/effect/cross-spawn-spawner.test.ts | 518 ++++++++++++++++++ 4 files changed, 1001 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/effect/cross-spawn-spawner.ts create mode 100644 packages/opencode/test/effect/cross-spawn-spawner.test.ts diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts new file mode 100644 index 0000000000..f7b8786d08 --- /dev/null +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -0,0 +1,476 @@ +import type * as Arr from "effect/Array" +import { NodeSink, NodeStream } from "@effect/platform-node" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as PlatformError from "effect/PlatformError" +import * as Predicate from "effect/Predicate" +import type * as Scope from "effect/Scope" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import * as ChildProcess from "effect/unstable/process/ChildProcess" +import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import { + ChildProcessSpawner, + ExitCode, + make as makeSpawner, + makeHandle, + ProcessId, +} from "effect/unstable/process/ChildProcessSpawner" +import * as NodeChildProcess from "node:child_process" +import { PassThrough } from "node:stream" +import launch from "cross-spawn" + +const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) + +const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => { + switch (err.code) { + case "ENOENT": + return "NotFound" + case "EACCES": + return "PermissionDenied" + case "EEXIST": + return "AlreadyExists" + case "EISDIR": + return "BadResource" + case "ENOTDIR": + return "BadResource" + case "EBUSY": + return "Busy" + case "ELOOP": + return "BadResource" + default: + return "Unknown" + } +} + +const flatten = (command: ChildProcess.Command) => { + const commands: Array = [] + const opts: Array = [] + + const walk = (cmd: ChildProcess.Command): void => { + switch (cmd._tag) { + case "StandardCommand": + commands.push(cmd) + return + case "PipedCommand": + walk(cmd.left) + opts.push(cmd.options) + walk(cmd.right) + return + } + } + + walk(command) + if (commands.length === 0) throw new Error("flatten produced empty commands array") + const [head, ...tail] = commands + return { + commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + opts, + } +} + +const toPlatformError = ( + method: string, + err: NodeJS.ErrnoException, + command: ChildProcess.Command, +): PlatformError.PlatformError => { + const cmd = flatten(command) + .commands.map((x) => `${x.command} ${x.args.join(" ")}`) + .join(" | ") + return PlatformError.systemError({ + _tag: toTag(err), + module: "ChildProcess", + method, + pathOrDescriptor: cmd, + syscall: err.syscall, + cause: err, + }) +} + +type ExitSignal = Deferred.Deferred + +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) { + if (Predicate.isUndefined(opts.cwd)) return undefined + yield* fs.access(opts.cwd) + return path.resolve(opts.cwd) + }) + + const env = (opts: ChildProcess.CommandOptions) => + opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env + + const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => + Stream.isStream(x) ? "pipe" : x + + const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined => + Sink.isSink(x) ? "pipe" : x + + const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { + const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } + if (Predicate.isUndefined(opts.stdin)) return cfg + if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } + if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } + return { + stream: opts.stdin.stream, + encoding: opts.stdin.encoding ?? cfg.encoding, + endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, + } + } + + const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { + const cfg = opts[key] + if (Predicate.isUndefined(cfg)) return { stream: "pipe" } + if (typeof cfg === "string") return { stream: cfg } + if (Sink.isSink(cfg)) return { stream: cfg } + return { stream: cfg.stream } + } + + const fds = (opts: ChildProcess.CommandOptions) => { + if (Predicate.isUndefined(opts.additionalFds)) return [] + return Object.entries(opts.additionalFds) + .flatMap(([name, config]) => { + const fd = ChildProcess.parseFdName(name) + return Predicate.isUndefined(fd) ? [] : [{ fd, config }] + }) + .toSorted((a, b) => a.fd - b.fd) + } + + const stdios = ( + sin: ChildProcess.StdinConfig, + sout: ChildProcess.StdoutConfig, + serr: ChildProcess.StderrConfig, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ): NodeChildProcess.StdioOptions => { + const pipe = (x: NodeChildProcess.IOType | undefined) => + process.platform === "win32" && x === "pipe" ? "overlapped" : x + const arr: Array = [ + pipe(input(sin.stream)), + pipe(output(sout.stream)), + pipe(output(serr.stream)), + ] + if (extra.length === 0) return arr as NodeChildProcess.StdioOptions + const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) + for (let i = 3; i <= max; i++) arr[i] = "ignore" + for (const x of extra) arr[x.fd] = pipe("pipe") + return arr as NodeChildProcess.StdioOptions + } + + const setupFds = Effect.fnUntraced(function* ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ) { + if (extra.length === 0) { + return { + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + } + } + + const ins = new Map>() + const outs = new Map>() + + for (const x of extra) { + const node = proc.stdio[x.fd] + switch (x.config.type) { + case "input": { + let sink: Sink.Sink = Sink.drain + if (node && "write" in node) { + sink = NodeSink.fromWritable({ + evaluate: () => node, + onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command), + endOnDone: true, + }) + } + if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink)) + ins.set(x.fd, sink) + break + } + case "output": { + let stream: Stream.Stream = Stream.empty + if (node && "read" in node) { + const tap = new PassThrough() + node.on("error", (err) => tap.destroy(toError(err))) + node.pipe(tap) + stream = NodeStream.fromReadable({ + evaluate: () => tap, + onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command), + }) + } + if (x.config.sink) stream = Stream.transduce(stream, x.config.sink) + outs.set(x.fd, stream) + break + } + } + } + + return { + getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain, + getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty, + } + }) + + const setupStdin = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + cfg: ChildProcess.StdinConfig, + ) => + Effect.suspend(() => { + let sink: Sink.Sink = Sink.drain + if (Predicate.isNotNull(proc.stdin)) { + sink = NodeSink.fromWritable({ + evaluate: () => proc.stdin!, + onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), + endOnDone: cfg.endOnDone, + encoding: cfg.encoding, + }) + } + if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) + return Effect.succeed(sink) + }) + + const setupOutput = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + out: ChildProcess.StdoutConfig, + err: ChildProcess.StderrConfig, + ) => { + let stdout = proc.stdout + ? NodeStream.fromReadable({ + evaluate: () => proc.stdout!, + onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command), + }) + : Stream.empty + let stderr = proc.stderr + ? NodeStream.fromReadable({ + evaluate: () => proc.stderr!, + onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command), + }) + : Stream.empty + + if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream) + if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream) + + return { stdout, stderr, all: Stream.merge(stdout, stderr) } + } + + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + Effect.callback((resume) => { + const signal = Deferred.makeUnsafe() + const proc = launch(command.command, command.args, opts) + let end = false + let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined + proc.on("error", (err) => { + resume(Effect.fail(toPlatformError("spawn", err, command))) + }) + proc.on("exit", (...args) => { + exit = args + }) + proc.on("close", (...args) => { + if (end) return + end = true + Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) + }) + proc.on("spawn", () => { + resume(Effect.succeed([proc, signal])) + }) + return Effect.sync(() => { + proc.kill("SIGTERM") + }) + }) + + const killGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => { + if (globalThis.process.platform === "win32") { + return Effect.callback((resume) => { + NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => { + if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command))) + resume(Effect.void) + }) + }) + } + + return Effect.try({ + try: () => { + globalThis.process.kill(-proc.pid!, signal) + }, + catch: (err) => toPlatformError("kill", toError(err), command), + }) + } + + const killOne = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => + Effect.suspend(() => { + if (proc.kill(signal)) return Effect.void + return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) + }) + + const timeout = + ( + proc: NodeChildProcess.ChildProcess, + command: ChildProcess.StandardCommand, + opts: ChildProcess.KillOptions | undefined, + ) => + ( + f: ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => Effect.Effect, + ) => { + const signal = opts?.killSignal ?? "SIGTERM" + if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) + return Effect.timeoutOrElse(f(command, proc, signal), { + duration: opts.forceKillAfter, + onTimeout: () => f(command, proc, "SIGKILL"), + }) + } + + const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { + const opt = from ?? "stdout" + switch (opt) { + case "stdout": + return handle.stdout + case "stderr": + return handle.stderr + case "all": + return handle.all + default: { + const fd = ChildProcess.parseFdName(opt) + return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout + } + } + } + + const spawnCommand: ( + command: ChildProcess.Command, + ) => Effect.Effect = Effect.fnUntraced( + function* (command) { + switch (command._tag) { + case "StandardCommand": { + const sin = stdin(command.options) + const sout = stdio(command.options, "stdout") + const serr = stdio(command.options, "stderr") + const extra = fds(command.options) + const dir = yield* cwd(command.options) + + const [proc, signal] = yield* Effect.acquireRelease( + spawn(command, { + cwd: dir, + env: env(command.options), + stdio: stdios(sin, sout, serr, extra), + detached: command.options.detached ?? process.platform !== "win32", + shell: command.options.shell, + windowsHide: process.platform === "win32", + }), + Effect.fnUntraced(function* ([proc, signal]) { + const done = yield* Deferred.isDone(signal) + const kill = timeout(proc, command, command.options) + if (done) { + const [code] = yield* Deferred.await(signal) + if (process.platform === "win32") return yield* Effect.void + if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) + return yield* Effect.void + } + return yield* kill((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore) + }), + ) + + const fd = yield* setupFds(command, proc, extra) + const out = setupOutput(command, proc, sout, serr) + return makeHandle({ + pid: ProcessId(proc.pid!), + stdin: yield* setupStdin(command, proc, sin), + stdout: out.stdout, + stderr: out.stderr, + all: out.all, + getInputFd: fd.getInputFd, + getOutputFd: fd.getOutputFd, + isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), + exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { + if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) + return Effect.fail( + toPlatformError( + "exitCode", + new Error(`Process interrupted due to receipt of signal: '${signal}'`), + command, + ), + ) + }), + kill: (opts?: ChildProcess.KillOptions) => + timeout( + proc, + command, + opts, + )((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + }) + } + case "PipedCommand": { + const flat = flatten(command) + const [head, ...tail] = flat.commands + let handle = spawnCommand(head) + for (let i = 0; i < tail.length; i++) { + const next = tail[i] + const opts = flat.opts[i] ?? {} + const sin = stdin(next.options) + const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from))) + const to = opts.to ?? "stdin" + if (to === "stdin") { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + const fd = ChildProcess.parseFdName(to) + if (Predicate.isUndefined(fd)) { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + additionalFds: { + ...next.options.additionalFds, + [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream }, + }, + }), + ) + } + return yield* handle + } + } + }, + ) + + return makeSpawner(spawnCommand) +}) + +export const layer: Layer.Layer = Layer.effect( + ChildProcessSpawner, + make, +) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 3551c861e4..912951a0ba 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,7 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { makeRunPromise } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -340,7 +341,7 @@ export namespace Installation { export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 5f8c5aeffd..7068545d26 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,8 +1,9 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { 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 * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" @@ -354,9 +355,9 @@ export namespace Snapshot { ) export const defaultLayer = layer.pipe( - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner + Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts new file mode 100644 index 0000000000..7fdcb61cd4 --- /dev/null +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -0,0 +1,518 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Exit, Layer, Stream } from "effect" +import type * as PlatformError from "effect/PlatformError" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +const fx = testEffect(live) + +function js(code: string, opts?: ChildProcess.CommandOptions) { + return ChildProcess.make("node", ["-e", code], opts) +} + +function decodeByteStream(stream: Stream.Stream) { + return Stream.runCollect(stream).pipe( + Effect.map((chunks) => { + const total = chunks.reduce((acc, x) => acc + x.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const chunk of chunks) { + out.set(chunk, off) + off += chunk.length + } + return new TextDecoder("utf-8").decode(out).trim() + }), + ) +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function gone(pid: number, timeout = 5_000) { + const end = Date.now() + timeout + while (Date.now() < end) { + if (!alive(pid)) return true + await Bun.sleep(50) + } + return !alive(pid) +} + +describe("cross-spawn spawner", () => { + describe("basic spawning", () => { + fx.effect( + "captures stdout", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), + ) + expect(out).toBe("ok") + }), + ) + + fx.effect( + "captures multiple lines", + Effect.gen(function* () { + const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("line1\nline2\nline3") + }), + ) + + fx.effect( + "returns exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(0)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + + fx.effect( + "returns non-zero exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(42)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(42)) + }), + ) + }) + + describe("cwd option", () => { + fx.effect( + "uses cwd when spawning commands", + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), + ), + ) + expect(out).toBe(tmp.path) + }), + ) + + fx.effect( + "fails for invalid cwd", + Effect.gen(function* () { + const exit = yield* Effect.exit( + ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("env option", () => { + fx.effect( + "passes environment variables with extendEnv", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { + env: { TEST_VAR: "test_value" }, + extendEnv: true, + }) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("test_value") + }), + ) + + fx.effect( + "passes multiple environment variables", + Effect.gen(function* () { + const handle = yield* js( + "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", + { + env: { VAR1: "one", VAR2: "two", VAR3: "three" }, + extendEnv: true, + }, + ) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("one-two-three") + }), + ) + }) + + describe("stderr", () => { + fx.effect( + "captures stderr output", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error message")') + const err = yield* decodeByteStream(handle.stderr) + expect(err).toBe("error message") + }), + ) + + fx.effect( + "captures both stdout and stderr", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")') + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + }), + ) + }) + + describe("combined output (all)", () => { + fx.effect( + "captures stdout via .all when no stderr", + Effect.gen(function* () { + const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stdout") + }), + ) + + fx.effect( + "captures stderr via .all when no stdout", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("hello from stderr")') + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stderr") + }), + ) + }) + + describe("stdin", () => { + fx.effect( + "allows providing standard input to a command", + Effect.gen(function* () { + const input = "a b c" + const stdin = Stream.make(Buffer.from(input, "utf-8")) + const handle = yield* js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + { stdin }, + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("a b c") + }), + ) + }) + + describe("process control", () => { + fx.effect( + "kills a running process", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js("setTimeout(() => {}, 10_000)") + yield* handle.kill() + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "kills a child when scope exits", + Effect.gen(function* () { + const pid = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* js("setInterval(() => {}, 10_000)") + return Number(handle.pid) + }), + ) + const done = yield* Effect.promise(() => gone(pid)) + expect(done).toBe(true) + }), + ) + + fx.effect( + "forceKillAfter escalates for stubborn processes", + Effect.gen(function* () { + if (process.platform === "win32") return + + const started = Date.now() + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') + yield* handle.kill({ forceKillAfter: 100 }) + return yield* handle.exitCode + }), + ) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "isRunning reflects process state", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("done")') + yield* handle.exitCode + const running = yield* handle.isRunning + expect(running).toBe(false) + }), + ) + }) + + describe("error handling", () => { + fx.effect( + "fails for invalid command", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("nonexistent-command-12345") + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + }) + + describe("pipeline", () => { + fx.effect( + "pipes stdout of one command to stdin of another", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO WORLD") + }), + ) + + fx.effect( + "three-stage pipeline", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO-WORLD") + }), + ) + + fx.effect( + "pipes stderr with { from: 'stderr' }", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "stderr" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("error") + }), + ) + + fx.effect( + "pipes combined output with { from: 'all' }", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "all" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toContain("stdout") + expect(out).toContain("stderr") + }), + ) + + fx.effect( + "pipes output fd3 with { from: 'fd3' }", + Effect.gen(function* () { + const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { + additionalFds: { fd3: { type: "output" } }, + }).pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "fd3" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("hello from fd3") + }), + ) + + fx.effect( + "pipes stdout to fd3", + Effect.gen(function* () { + if (process.platform === "win32") return + + const handle = yield* js('process.stdout.write("hello from stdout")').pipe( + ChildProcess.pipeTo(js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))'), { to: "fd3" }), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("hello from stdout") + }), + ) + }) + + describe("additional fds", () => { + fx.effect( + "reads data from output fd3", + Effect.gen(function* () { + const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { + additionalFds: { fd3: { type: "output" } }, + }) + const out = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(out).toBe("hello from fd3") + }), + ) + + fx.effect( + "writes data to input fd3", + Effect.gen(function* () { + if (process.platform === "win32") return + + const input = Stream.make(new TextEncoder().encode("data from parent")) + const handle = yield* js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))', { + additionalFds: { fd3: { type: "input", stream: input } }, + }) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("data from parent") + }), + ) + + fx.effect( + "returns empty stream for unconfigured fd", + Effect.gen(function* () { + const handle = + process.platform === "win32" + ? yield* js('process.stdout.write("test")') + : yield* ChildProcess.make("echo", ["test"]) + const out = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(out).toBe("") + }), + ) + + fx.effect( + "works alongside normal stdout and stderr", + Effect.gen(function* () { + const handle = yield* js( + 'require("node:fs").writeSync(3, "fd3\\n"); process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")', + { + additionalFds: { fd3: { type: "output" } }, + }, + ) + const stdout = yield* decodeByteStream(handle.stdout) + const stderr = yield* decodeByteStream(handle.stderr) + const fd3 = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + expect(fd3).toBe("fd3") + }), + ) + }) + + describe("large output", () => { + fx.effect( + "does not deadlock on large stdout", + Effect.gen(function* () { + const handle = yield* js("for (let i = 1; i <= 100000; i++) process.stdout.write(`${i}\\n`)") + const out = yield* handle.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ) + yield* handle.exitCode + const lines = out.trim().split("\n") + expect(lines.length).toBe(100000) + expect(lines[0]).toBe("1") + expect(lines[99999]).toBe("100000") + }), + { timeout: 10_000 }, + ) + }) + + describe("Windows-specific", () => { + fx.effect( + "uses shell routing on Windows", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { + shell: true, + extendEnv: true, + env: { OPENCODE_TEST_SHELL: "ok" }, + }), + ), + ) + expect(out).toContain("OPENCODE_TEST_SHELL=ok") + }), + ) + + fx.effect( + "runs cmd scripts with spaces on Windows without shell", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const dir = path.join(tmp.path, "with space") + const file = path.join(dir, "echo cmd.cmd") + + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) + + const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.exitCode( + ChildProcess.make(file, ["--stdio"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ), + ) + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + }) +}) From 037077285ac36b8a427aa330d331e099360f1e55 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 11:30:39 -0500 Subject: [PATCH 079/108] fix: better nix hash detection (#18957) --- .github/workflows/nix-hashes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 2529c14c20..9ebdb01882 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(grep -oE 'got:\s*sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" From 31c4a4fb478d765d39ead26f81db9bf5ab54eb6c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 16:43:24 +0000 Subject: [PATCH 080/108] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 2065007431..944cb2c5b6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", - "aarch64-linux": "sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", - "aarch64-darwin": "sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", - "x86_64-darwin": "sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" + "x86_64-linux": "got:sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", + "aarch64-linux": "got:sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", + "aarch64-darwin": "got:sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", + "x86_64-darwin": "got:sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" } } From 7c5ed771c36f5acbd47a1070afc1935e8a50650b Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 25 Mar 2026 01:03:01 +0800 Subject: [PATCH 081/108] fix: update Feishu community links for zh locales (#18975) --- README.zh.md | 2 +- README.zht.md | 2 +- packages/console/app/src/routes/feishu.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.zh.md b/README.zh.md index 0859ed11d0..46d9f761cb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) +**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md index b7d8b8fc47..7ef51d8fdd 100644 --- a/README.zht.md +++ b/README.zht.md @@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) +**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/packages/console/app/src/routes/feishu.ts b/packages/console/app/src/routes/feishu.ts index 35d2fcb0e2..3366e7208b 100644 --- a/packages/console/app/src/routes/feishu.ts +++ b/packages/console/app/src/routes/feishu.ts @@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router" export async function GET() { return redirect( - "https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true", + "https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true", ) } From 5c1bb5de86d62bd598a89cd1ba0c1c02de103a90 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 13:04:04 -0400 Subject: [PATCH 082/108] fix: remove flaky cross-spawn spawner tests (#18977) --- .../test/effect/cross-spawn-spawner.test.ts | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 7fdcb61cd4..6da0715212 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -351,122 +351,8 @@ describe("cross-spawn spawner", () => { }), ) - fx.effect( - "pipes output fd3 with { from: 'fd3' }", - Effect.gen(function* () { - const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { - additionalFds: { fd3: { type: "output" } }, - }).pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', - ), - { from: "fd3" }, - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("hello from fd3") - }), - ) - - fx.effect( - "pipes stdout to fd3", - Effect.gen(function* () { - if (process.platform === "win32") return - - const handle = yield* js('process.stdout.write("hello from stdout")').pipe( - ChildProcess.pipeTo(js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))'), { to: "fd3" }), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("hello from stdout") - }), - ) }) - describe("additional fds", () => { - fx.effect( - "reads data from output fd3", - Effect.gen(function* () { - const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { - additionalFds: { fd3: { type: "output" } }, - }) - const out = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(out).toBe("hello from fd3") - }), - ) - - fx.effect( - "writes data to input fd3", - Effect.gen(function* () { - if (process.platform === "win32") return - - const input = Stream.make(new TextEncoder().encode("data from parent")) - const handle = yield* js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))', { - additionalFds: { fd3: { type: "input", stream: input } }, - }) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("data from parent") - }), - ) - - fx.effect( - "returns empty stream for unconfigured fd", - Effect.gen(function* () { - const handle = - process.platform === "win32" - ? yield* js('process.stdout.write("test")') - : yield* ChildProcess.make("echo", ["test"]) - const out = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(out).toBe("") - }), - ) - - fx.effect( - "works alongside normal stdout and stderr", - Effect.gen(function* () { - const handle = yield* js( - 'require("node:fs").writeSync(3, "fd3\\n"); process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")', - { - additionalFds: { fd3: { type: "output" } }, - }, - ) - const stdout = yield* decodeByteStream(handle.stdout) - const stderr = yield* decodeByteStream(handle.stderr) - const fd3 = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(stdout).toBe("stdout") - expect(stderr).toBe("stderr") - expect(fd3).toBe("fd3") - }), - ) - }) - - describe("large output", () => { - fx.effect( - "does not deadlock on large stdout", - Effect.gen(function* () { - const handle = yield* js("for (let i = 1; i <= 100000; i++) process.stdout.write(`${i}\\n`)") - const out = yield* handle.stdout.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ) - yield* handle.exitCode - const lines = out.trim().split("\n") - expect(lines.length).toBe(100000) - expect(lines[0]).toBe("1") - expect(lines[99999]).toBe("100000") - }), - { timeout: 10_000 }, - ) - }) describe("Windows-specific", () => { fx.effect( From 1d3232b3885daa471309abab59e145d8e16f1736 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 17:05:02 +0000 Subject: [PATCH 083/108] chore: generate --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 6da0715212..08cae76e2f 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -350,10 +350,8 @@ describe("cross-spawn spawner", () => { expect(out).toContain("stderr") }), ) - }) - describe("Windows-specific", () => { fx.effect( "uses shell routing on Windows", From 1238d1f61acccf05330ff8fb59f3e355239b5f82 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 12:32:48 -0500 Subject: [PATCH 084/108] fix: nix hash update parsing (#18979) --- .github/workflows/nix-hashes.yml | 2 +- nix/hashes.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 9ebdb01882..47385d20c6 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oE 'got:\s*sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(grep -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" diff --git a/nix/hashes.json b/nix/hashes.json index 944cb2c5b6..2065007431 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "got:sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", - "aarch64-linux": "got:sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", - "aarch64-darwin": "got:sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", - "x86_64-darwin": "got:sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" + "x86_64-linux": "sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", + "aarch64-linux": "sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", + "aarch64-darwin": "sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", + "x86_64-darwin": "sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" } } From 9330bc5339b3ca82975f768200450d4c9aabcd35 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Tue, 24 Mar 2026 18:33:18 +0100 Subject: [PATCH 085/108] fix: route GitLab Duo Workflow system prompt via flowConfig (#18928) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- packages/opencode/src/session/llm.ts | 22 +++++++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 8db8852a0b..36b8f77483 100644 --- a/bun.lock +++ b/bun.lock @@ -358,7 +358,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "5.3.1", + "gitlab-ai-provider": "5.3.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3036,7 +3036,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@5.3.1", "", { "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-QeNP2af/5wyOHYaLvDxn72n4xbMbJNqRiKExZJM8MnynebnqnoaJoojbtue7roCl/XcnjX6Of2+oc7hS44S45Q=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.3.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-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 39b4e6232b..97a6457cf9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -121,7 +121,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "5.3.1", + "gitlab-ai-provider": "5.3.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a22c6d8560..075f070e42 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -113,17 +113,20 @@ export namespace LLM { options.instructions = system.join("\n") } + const isWorkflow = language instanceof GitLabWorkflowLanguageModel const messages = isOpenaiOauth ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] const params = await Plugin.trigger( "chat.params", @@ -190,6 +193,7 @@ export namespace LLM { // and results sent back over the WebSocket. if (language instanceof GitLabWorkflowLanguageModel) { const workflowModel = language + workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { const t = tools[toolName] if (!t || !t.execute) { From 235a82aea97cd35c190bc95e916be5bdc0cce04a Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 12:50:25 -0500 Subject: [PATCH 086/108] chore: update flake.lock (#18976) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 59eb118fa4..805be8739b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1772091128, - "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", + "lastModified": 1773909469, + "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3f0336406035444b4a24b942788334af5f906259", + "rev": "7149c06513f335be57f26fcbbbe34afda923882b", "type": "github" }, "original": { From 814a515a8a2f474585ea061a99e1058b2bb8b374 Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Tue, 24 Mar 2026 12:50:55 -0500 Subject: [PATCH 087/108] =?UTF-8?q?fix:=20improve=20plugin=20system=20robu?= =?UTF-8?q?stness=20=E2=80=94=20agent/command=20resolution,=20async=20erro?= =?UTF-8?q?rs,=20hook=20timing,=20two-phase=20init=20(#18280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/plugin/index.ts | 6 +- .../opencode/src/server/routes/session.ts | 10 ++- packages/opencode/src/session/prompt.ts | 52 ++++++++++++- .../test/plugin/auth-override.test.ts | 16 ++++ .../test/server/session-messages.test.ts | 13 ++++ packages/opencode/test/session/prompt.test.ts | 76 +++++++++++++++++++ 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 57dcff8f67..e519f9f350 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -136,7 +136,11 @@ export namespace Plugin { // Notify plugins of current config for (const hook of hooks) { - await (hook as any).config?.(cfg) + try { + await (hook as any).config?.(cfg) + } catch (err) { + log.error("plugin config hook failed", { error: err }) + } } }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index abc820c2af..3c9ebfdc5e 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Bus } from "../../bus" +import { NamedError } from "@opencode-ai/util/error" const log = Log.create({ service: "server" }) @@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() => return stream(c, async () => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) + SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) }) }, ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b..b3c34539e7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -418,6 +418,16 @@ export namespace SessionPrompt { ) let executionError: Error | undefined const taskAgent = await Agent.get(task.agent) + if (!taskAgent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -560,6 +570,16 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -964,7 +984,18 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agentName = input.agent || (await Agent.defaultAgent()) + const agent = await Agent.get(agentName) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1531,6 +1562,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the await SessionRevert.cleanup(session) } const agent = await Agent.get(input.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -1783,7 +1824,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the log.info("command", input) const command = await Command.get(input.command) if (!command) { - throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) + const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) + const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index b967262254..667b7ba9aa 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -54,3 +54,19 @@ describe("plugin.auth-override", () => { expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }, 30000) // Increased timeout for plugin installation }) + +const file = path.join(import.meta.dir, "../../src/plugin/index.ts") + +describe("plugin.config-hook-error-isolation", () => { + test("config hooks are individually error-isolated in the layer factory", async () => { + const src = await Bun.file(file).text() + + // The config hook try/catch lives in the InstanceState factory (layer definition), + // not in init() which now just delegates to the Effect service. + expect(src).toContain("plugin config hook failed") + + const pattern = + /for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/ + expect(pattern.test(src)).toBe(true) + }) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index ee4c51646f..91e0fd9263 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -117,3 +117,16 @@ describe("session messages endpoint", () => { }) }) }) + +describe("session.prompt_async error handling", () => { + test("prompt_async route has error handler for detached prompt call", async () => { + const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text() + const start = src.indexOf('"/:sessionID/prompt_async"') + const end = src.indexOf('"/:sessionID/command"', start) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + const route = src.slice(start, end) + expect(route).toContain(".catch(") + expect(route).toContain("Bus.publish(Session.Event.Error") + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab..7d1d429057 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,5 +1,6 @@ import path from "path" import { describe, expect, test } from "bun:test" +import { NamedError } from "@opencode-ai/util/error" import { fileURLToPath } from "url" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -210,3 +211,78 @@ describe("session.prompt agent variant", () => { } }) }) + +describe("session.agent-resolution", () => { + test("unknown agent throws typed error", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') + } + }, + }) + }, 30000) + + test("unknown agent error includes available agent names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain("build") + } + }, + }) + }, 30000) + + test("unknown command throws typed error with available names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.command({ + sessionID: session.id, + command: "nonexistent-command-xyz", + arguments: "", + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') + expect(err.data.message).toContain("init") + } + }, + }) + }, 30000) +}) From 539b01f20fc3677155b3bdbb428c69423a805578 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 14:04:22 -0400 Subject: [PATCH 088/108] effectify Project service (#18808) --- packages/opencode/specs/effect-migration.md | 2 +- packages/opencode/src/project/project.ts | 728 ++++++++++-------- .../opencode/src/server/routes/project.ts | 2 +- .../opencode/test/project/project.test.ts | 302 +++++--- 4 files changed, 578 insertions(+), 456 deletions(-) diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 12017b0e45..cf217871da 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -173,6 +173,6 @@ Still open and likely worth migrating: - [ ] `SessionPrompt` - [ ] `SessionCompaction` - [ ] `Provider` -- [ ] `Project` +- [x] `Project` - [ ] `LSP` - [ ] `MCP` diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c..3d20f58d45 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,36 +1,23 @@ import z from "zod" -import { Filesystem } from "../util/filesystem" -import path from "path" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import { Log } from "../util/log" import { Flag } from "@/flag/flag" -import { fn } from "@opencode-ai/util/fn" import { BusEvent } from "@/bus/bus-event" -import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" -import { existsSync } from "fs" -import { git } from "../util/git" -import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { - if (!name) return cwd - // git output includes trailing newlines; keep path whitespace intact. - name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd - - name = Filesystem.windowsPath(name) - - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) - } - export const Info = z .object({ id: ProjectID.zod, @@ -73,7 +60,7 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: ProjectID.make(row.id), + id: row.id, worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, @@ -88,245 +75,401 @@ export namespace Project { } } - function readCachedId(dir: string) { - return Filesystem.readText(path.join(dir, "opencode")) - .then((x) => x.trim()) - .then(ProjectID.make) - .catch(() => undefined) + export const UpdateInput = z.object({ + projectID: ProjectID.zod, + name: z.string().optional(), + icon: Info.shape.icon.optional(), + commands: Info.shape.commands.optional(), + }) + export type UpdateInput = z.infer + + // --------------------------------------------------------------------------- + // Effect service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> + readonly discover: (input: Info) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: ProjectID) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect + readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect + readonly setInitialized: (id: ProjectID) => Effect.Effect + readonly sandboxes: (id: ProjectID) => Effect.Effect + readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect } - export async function fromDirectory(directory: string) { - log.info("fromDirectory", { directory }) + export class Service extends ServiceMap.Service()("@opencode/Project") {} - const data = await iife(async () => { - const matches = Filesystem.up({ targets: [".git"], start: directory }) - const dotgit = await matches.next().then((x) => x.value) - await matches.return() - if (dotgit) { - let sandbox = path.dirname(dotgit) + type GitResult = { code: number; text: string; stderr: string } - const gitBinary = which("git") + export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const pathSvc = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - // cached id calculation - let id = await readCachedId(dotgit) + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + 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(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), + ) - if (!gitBinary) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) - const worktree = await git(["rev-parse", "--git-common-dir"], { - cwd: sandbox, - }) - .then(async (result) => { - const common = gitpath(sandbox, await result.text()) - // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) - }) - .catch(() => undefined) + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + payload: { type: Event.Updated.type, properties: data }, + }), + ) - if (!worktree) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) - // In the case of a git worktree, it can't cache the id - // because `.git` is not a folder, but it always needs the - // same project id as the common dir, so we resolve it now - if (id == null) { - id = await readCachedId(path.join(worktree, ".git")) - } + const resolveGitPath = (cwd: string, name: string) => { + if (!name) return cwd + name = name.replace(/[\r\n]+$/, "") + if (!name) return cwd + name = AppFileSystem.windowsPath(name) + if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name) + return pathSvc.resolve(cwd, name) + } - // generate id from root commit - if (!id) { - const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { - cwd: sandbox, - }) - .then(async (result) => - (await result.text()) - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted(), - ) - .catch(() => undefined) + const scope = yield* Scope.Scope - if (!roots) { + const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { + return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe( + Effect.map((x) => x.trim()), + Effect.map(ProjectID.make), + Effect.catch(() => Effect.succeed(undefined)), + ) + }) + + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { + log.info("fromDirectory", { directory }) + + // Phase 1: discover git info + type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } + + const data: DiscoveryResult = yield* Effect.gen(function* () { + const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgit = dotgitMatches[0] + + if (!dotgit) { return { id: ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + worktree: "/", + sandbox: "/", + vcs: fakeVcs, } } - id = roots[0] ? ProjectID.make(roots[0]) : undefined - if (id) { - // Write to common dir so the cache is shared across worktrees. - await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined) - } - } + let sandbox = pathSvc.dirname(dotgit) + const gitBinary = yield* Effect.sync(() => which("git")) + let id = yield* readCachedProjectId(dotgit) - if (!id) { - return { - id: ProjectID.global, - worktree: sandbox, - sandbox, - vcs: "git", + if (!gitBinary) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } } - } - const top = await git(["rev-parse", "--show-toplevel"], { - cwd: sandbox, + const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox }) + if (commonDir.code !== 0) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + const worktree = (() => { + const common = resolveGitPath(sandbox, commonDir.text.trim()) + return common === sandbox ? sandbox : pathSvc.dirname(common) + })() + + if (id == null) { + id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + } + + if (!id) { + const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox }) + const roots = revList.text + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + + id = roots[0] ? ProjectID.make(roots[0]) : undefined + if (id) { + yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) + } + } + + if (!id) { + return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const } + } + + const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox }) + if (topLevel.code !== 0) { + return { + id, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + sandbox = resolveGitPath(sandbox, topLevel.text.trim()) + + return { id, sandbox, worktree, vcs: "git" as const } }) - .then(async (result) => gitpath(sandbox, await result.text())) - .catch(() => undefined) - if (!top) { - return { - id, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + // Phase 2: upsert + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) + const existing = row + ? fromRow(row) + : { + id: data.id, + worktree: data.worktree, + vcs: data.vcs, + sandboxes: [] as string[], + time: { created: Date.now(), updated: Date.now() }, + } - sandbox = top + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) + yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - return { - id, - sandbox, - worktree, - vcs: "git", - } - } - - return { - id: ProjectID.global, - worktree: "/", - sandbox: "/", - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - }) - - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row - ? fromRow(row) - : { - id: data.id, + const result: Info = { + ...existing, worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], - time: { - created: Date.now(), - updated: Date.now(), - }, + vcs: data.vcs, + time: { ...existing.time, updated: Date.now() }, + } + if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) + result.sandboxes.push(data.sandbox) + result.sandboxes = yield* Effect.forEach( + result.sandboxes, + (s) => + fsys.exists(s).pipe( + Effect.orDie, + Effect.map((exists) => (exists ? s : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + + yield* db((d) => + d + .insert(ProjectTable) + .values({ + id: result.id, + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }, + }) + .run(), + ) + + if (data.id !== ProjectID.global) { + yield* db((d) => + d + .update(SessionTable) + .set({ project_id: data.id }) + .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) + .run(), + ) } - if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) + yield* emitUpdated(result) + return { project: result, sandbox: data.sandbox } + }) - const result: Info = { - ...existing, - worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - time: { - ...existing.time, - updated: Date.now(), - }, - } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) - const insert = { - id: result.id, - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_created: result.time.created, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - const updateSet = { - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - Database.use((db) => - db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), - ) - // Runs after upsert so the target project row exists (FK constraint). - // Runs on every startup because sessions created before git init - // accumulate under "global" and need migrating whenever they appear. - if (data.id !== ProjectID.global) { - Database.use((db) => - db - .update(SessionTable) - .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) - .run(), - ) - } - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: result, - }, - }) - return { project: result, sandbox: data.sandbox } + const discover = Effect.fn("Project.discover")(function* (input: Info) { + if (input.vcs !== "git") return + if (input.icon?.override) return + if (input.icon?.url) return + + const matches = yield* fsys + .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + cwd: input.worktree, + absolute: true, + include: "file", + }) + .pipe(Effect.orDie) + const shortest = matches.sort((a, b) => a.length - b.length)[0] + if (!shortest) return + + const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie) + const base64 = Buffer.from(buffer).toString("base64") + const mime = AppFileSystem.mimeType(shortest) + const url = `data:${mime};base64,${base64}` + yield* update({ projectID: input.id, icon: { url } }) + }) + + const list = Effect.fn("Project.list")(function* () { + return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) + }) + + const get = Effect.fn("Project.get")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + return row ? fromRow(row) : undefined + }) + + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${input.projectID}`) + const data = fromRow(result) + yield* emitUpdated(data) + return data + }) + + const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") + const result = yield* git(["init", "--quiet"], { cwd: input.directory }) + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") + } + const { project } = yield* fromDirectory(input.directory) + return project + }) + + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { + yield* db((d) => + d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), + ) + }) + + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) return [] + const data = fromRow(row) + return yield* Effect.forEach( + data.sandboxes, + (dir) => fsys.isDir(dir).pipe(Effect.orDie, Effect.map((ok) => (ok ? dir : undefined))), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + }) + + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = [...row.sandboxes] + if (!sboxes.includes(directory)) sboxes.push(directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = row.sandboxes.filter((s) => s !== directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + return Service.of({ + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) + const runPromise = makeRunPromise(Service, defaultLayer) + + // --------------------------------------------------------------------------- + // Promise-based API (delegates to Effect service via runPromise) + // --------------------------------------------------------------------------- + + export function fromDirectory(directory: string) { + return runPromise((svc) => svc.fromDirectory(directory)) } - export async function discover(input: Info) { - if (input.vcs !== "git") return - if (input.icon?.override) return - if (input.icon?.url) return - const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { - cwd: input.worktree, - absolute: true, - include: "file", - }) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return - const buffer = await Filesystem.readBytes(shortest) - const base64 = buffer.toString("base64") - const mime = Filesystem.mimeType(shortest) || "image/png" - const url = `data:${mime};base64,${base64}` - await update({ - projectID: input.id, - icon: { - url, - }, - }) - return - } - - export function setInitialized(id: ProjectID) { - Database.use((db) => - db - .update(ProjectTable) - .set({ - time_initialized: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .run(), - ) + export function discover(input: Info) { + return runPromise((svc) => svc.discover(input)) } export function list() { @@ -345,112 +488,29 @@ export namespace Project { return fromRow(row) } - export async function initGit(input: { directory: string; project: Info }) { - if (input.project.vcs === "git") return input.project - if (!which("git")) throw new Error("Git is not installed") - - const result = await git(["init", "--quiet"], { - cwd: input.directory, - }) - if (result.exitCode !== 0) { - const text = result.stderr.toString().trim() || result.text().trim() - throw new Error(text || "Failed to initialize git repository") - } - - return (await fromDirectory(input.directory)).project - } - - export const update = fn( - z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), - }), - async (input) => { - const id = ProjectID.make(input.projectID) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ - name: input.name, - icon_url: input.icon?.url, - icon_color: input.icon?.color, - commands: input.commands, - time_updated: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data - }, - ) - - export async function sandboxes(id: ProjectID) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return [] - const data = fromRow(row) - const valid: string[] = [] - for (const dir of data.sandboxes) { - const s = Filesystem.stat(dir) - if (s?.isDirectory()) valid.push(dir) - } - return valid - } - - export async function addSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = [...row.sandboxes] - if (!sandboxes.includes(directory)) sandboxes.push(directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), + export function setInitialized(id: ProjectID) { + Database.use((db) => + db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data } - export async function removeSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = row.sandboxes.filter((s) => s !== directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data + export function initGit(input: { directory: string; project: Info }) { + return runPromise((svc) => svc.initGit(input)) + } + + export function update(input: UpdateInput) { + return runPromise((svc) => svc.update(input)) + } + + export function sandboxes(id: ProjectID) { + return runPromise((svc) => svc.sandboxes(id)) + } + + export function addSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.addSandbox(id, directory)) + } + + export function removeSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.removeSandbox(id, directory)) } } diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 6cd51ac958..e5dd5782d6 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.update.schema.omit({ projectID: true })), + validator("json", Project.UpdateInput.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID const body = c.req.valid("json") diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f..523f0711fd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,78 +1,69 @@ -import { describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" -import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" +import { Effect, Layer, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { AppFileSystem } from "../../src/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" Log.init({ print: false }) -const gitModule = await import("../../src/util/git") -const originalGit = gitModule.git +const encoder = new TextEncoder() -type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" -let mode: Mode = "none" - -mock.module("../../src/util/git", () => ({ - git: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, -})) - -async function withMode(next: Mode, run: () => Promise) { - const prev = mode - mode = next - try { - await run() - } finally { - mode = prev - } +/** + * Creates a mock ChildProcessSpawner layer that intercepts git subcommands + * matching `failArg` and returns exit code 128, while delegating everything + * else to the real CrossSpawnSpawner. + */ +function mockGitFailure(failArg: string) { + return Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const real = yield* ChildProcessSpawner.ChildProcessSpawner + return ChildProcessSpawner.make( + Effect.fnUntraced(function* (command) { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + if (std?.command === "git" && std.args.some((a) => a === failArg)) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: Stream.empty, + stderr: Stream.make(encoder.encode("fatal: simulated failure\n")), + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }) + } + return yield* real.spawn(command) + }), + ) + }), + ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) } -async function loadProject() { - return (await import("../../src/project/project")).Project +function projectLayerWithFailure(failArg: string) { + return Project.layer.pipe( + Layer.provide(mockGitFailure(failArg)), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), + ) } describe("Project.fromDirectory", () => { test("should handle git repository with no commits", async () => { - const p = await loadProject() await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) @@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(false) + expect(await Bun.file(opencodeFile).exists()).toBe(false) }) test("should handle git repository with commits", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) @@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(true) + expect(await Bun.file(opencodeFile).exists()).toBe(true) }) - test("keeps git vcs when rev-list exits non-zero with empty output", async () => { - const p = await loadProject() + test("returns global for non-git directory", async () => { + await using tmp = await tmpdir() + const { project } = await Project.fromDirectory(tmp.path) + expect(project.id).toBe(ProjectID.global) + }) + + test("derives stable project ID from root commit", async () => { + await using tmp = await tmpdir({ git: true }) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(tmp.path) + expect(b.id).toBe(a.id) + }) +}) + +describe("Project.fromDirectory git failure paths", () => { + test("keeps vcs when rev-list exits non-zero (no commits)", async () => { await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - await withMode("rev-list-fail", async () => { - const { project } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) - }) + // rev-list fails because HEAD doesn't exist yet — this is the natural scenario + const { project } = await Project.fromDirectory(tmp.path) + expect(project.vcs).toBe("git") + expect(project.id).toBe(ProjectID.global) + expect(project.worktree).toBe(tmp.path) }) - test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles show-toplevel failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--show-toplevel") - await withMode("top-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) - test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles git-common-dir failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--git-common-dir") - await withMode("common-dir-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) }) describe("Project.fromDirectory with worktrees", () => { test("should set worktree to root when called from root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project, sandbox } = await p.fromDirectory(tmp.path) + const { project, sandbox } = await Project.fromDirectory(tmp.path) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(tmp.path) @@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should set worktree to root when called from a worktree", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") try { await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() - const { project, sandbox } = await p.fromDirectory(worktreePath) + const { project, sandbox } = await Project.fromDirectory(worktreePath) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(worktreePath) @@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => { }) test("worktree should share project ID with main repo", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project: main } = await p.fromDirectory(tmp.path) + const { project: main } = await Project.fromDirectory(tmp.path) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") try { await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() - const { project: wt } = await p.fromDirectory(worktreePath) + const { project: wt } = await Project.fromDirectory(worktreePath) expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file const cache = path.join(tmp.path, ".git", "opencode") - const exists = await Filesystem.exists(cache) + const exists = await Bun.file(cache).exists() expect(exists).toBe(true) } finally { await $`git worktree remove ${worktreePath}` @@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("separate clones of the same repo should share project ID", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) // Create a bare remote, push, then clone into a second directory @@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone ${bare} ${clone}`.quiet() - const { project: a } = await p.fromDirectory(tmp.path) - const { project: b } = await p.fromDirectory(clone) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(clone) expect(b.id).toBe(a.id) } finally { @@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should accumulate multiple worktrees in sandboxes", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") @@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() - await p.fromDirectory(worktree1) - const { project } = await p.fromDirectory(worktree2) + await Project.fromDirectory(worktree1) + const { project } = await Project.fromDirectory(worktree2) expect(project.worktree).toBe(tmp.path) expect(project.sandboxes).toContain(worktree1) @@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { test("should discover favicon.png in root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await Bun.write(path.join(tmp.path, "favicon.png"), pngData) - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -268,13 +261,12 @@ describe("Project.discover", () => { }) test("should not discover non-image files", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -344,8 +336,6 @@ describe("Project.update", () => { }) test("should throw error when project not found", async () => { - await using tmp = await tmpdir({ git: true }) - await expect( Project.update({ projectID: ProjectID.make("nonexistent-project-id"), @@ -358,22 +348,22 @@ describe("Project.update", () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) - let eventFired = false let eventPayload: any = null + const on = (data: any) => { eventPayload = data } + GlobalBus.on("event", on) - GlobalBus.on("event", (data) => { - eventFired = true - eventPayload = data - }) + try { + await Project.update({ + projectID: project.id, + name: "Updated Name", + }) - await Project.update({ - projectID: project.id, - name: "Updated Name", - }) - - expect(eventFired).toBe(true) - expect(eventPayload.payload.type).toBe("project.updated") - expect(eventPayload.payload.properties.name).toBe("Updated Name") + expect(eventPayload).not.toBeNull() + expect(eventPayload.payload.type).toBe("project.updated") + expect(eventPayload.payload.properties.name).toBe("Updated Name") + } finally { + GlobalBus.off("event", on) + } }) test("should update multiple fields at once", async () => { @@ -393,3 +383,75 @@ describe("Project.update", () => { expect(updated.commands?.start).toBe("make start") }) }) + +describe("Project.list and Project.get", () => { + test("list returns all projects", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const all = Project.list() + expect(all.length).toBeGreaterThan(0) + expect(all.find((p) => p.id === project.id)).toBeDefined() + }) + + test("get returns project by id", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const found = Project.get(project.id) + expect(found).toBeDefined() + expect(found!.id).toBe(project.id) + }) + + test("get returns undefined for unknown id", () => { + const found = Project.get(ProjectID.make("nonexistent")) + expect(found).toBeUndefined() + }) +}) + +describe("Project.setInitialized", () => { + test("sets time_initialized on project", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + expect(project.time.initialized).toBeUndefined() + + Project.setInitialized(project.id) + + const updated = Project.get(project.id) + expect(updated?.time.initialized).toBeDefined() + }) +}) + +describe("Project.addSandbox and Project.removeSandbox", () => { + test("addSandbox adds directory and removeSandbox removes it", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-test") + + await Project.addSandbox(project.id, sandboxDir) + + let found = Project.get(project.id) + expect(found?.sandboxes).toContain(sandboxDir) + + await Project.removeSandbox(project.id, sandboxDir) + + found = Project.get(project.id) + expect(found?.sandboxes).not.toContain(sandboxDir) + }) + + test("addSandbox emits GlobalBus event", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-event") + + const events: any[] = [] + const on = (evt: any) => events.push(evt) + GlobalBus.on("event", on) + + await Project.addSandbox(project.id, sandboxDir) + + GlobalBus.off("event", on) + expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) + }) +}) From 42a773481e4d50a59784d514d81257330de38ca9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:03:55 -0500 Subject: [PATCH 089/108] fix(app): sidebar truncation --- .../app/src/pages/layout/sidebar-items.tsx | 179 +++++++++--------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index a9627c5dbc..75dada05f0 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -104,7 +104,7 @@ const SessionRow = (props: { }): JSX.Element => ( -
-
- }> - - - - -
- - -
- - 0}> -
- - -
- - {props.session.title} - +
+ }> + + + + +
+ + +
+ + 0}> +
+ +
+ {props.session.title} ) @@ -167,7 +163,11 @@ const SessionHoverPreview = (props: { placement="right-start" gutter={16} shift={-2} - trigger={
{props.trigger}
} + trigger={ +
+ {props.trigger} +
+ } open={props.hoverSession() === props.session.id} onOpenChange={(open) => { if (!open) { @@ -309,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
- - {item} - - } - > - { - if (!isActive()) - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) +
+
+ + {item} + + } + > + { + if (!isActive()) + layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) - navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + }} + trigger={item} + /> + +
+ +
- - -
- - { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - + > + + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + +
) @@ -386,30 +395,26 @@ export const NewSessionItem = (props: {
{ props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} > -
-
- -
- - {label} - +
+
+ {label}
) return ( -
+
+ {item} } From 8994cbfc0f57aede5a34a202f778d3a4385908af Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 18:05:43 +0000 Subject: [PATCH 090/108] chore: generate --- packages/opencode/src/project/project.ts | 6 +++++- packages/opencode/test/project/project.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 3d20f58d45..256be36959 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -399,7 +399,11 @@ export namespace Project { const data = fromRow(row) return yield* Effect.forEach( data.sandboxes, - (dir) => fsys.isDir(dir).pipe(Effect.orDie, Effect.map((ok) => (ok ? dir : undefined))), + (dir) => + fsys.isDir(dir).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? dir : undefined)), + ), { concurrency: "unbounded" }, ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 523f0711fd..b030e6cbcd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -349,7 +349,9 @@ describe("Project.update", () => { const { project } = await Project.fromDirectory(tmp.path) let eventPayload: any = null - const on = (data: any) => { eventPayload = data } + const on = (data: any) => { + eventPayload = data + } GlobalBus.on("event", on) try { From 2c1d8a90d567d65ac044b2feaf2ee886318247ec Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 13:06:46 -0500 Subject: [PATCH 091/108] fix: nix hash update parsing... again (#18989) --- .github/workflows/nix-hashes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 47385d20c6..9d94682f11 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" From 5e684c6e80d30a77ba02db013c61b8ecfe420f7f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:15:23 -0500 Subject: [PATCH 092/108] chore: effectify agent.ts (#18971) Co-authored-by: Kit Langton --- packages/opencode/src/agent/agent.ts | 609 +++++++++++++++------------ 1 file changed, 340 insertions(+), 269 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 30d0986144..72b2869641 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -3,7 +3,6 @@ import z from "zod" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" -import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncate" import { Auth } from "../auth" @@ -20,6 +19,9 @@ import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" +import { Effect, ServiceMap, Layer } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" export namespace Agent { export const Info = z @@ -49,295 +51,364 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + export interface Interface { + readonly get: (agent: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly generate: (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) => Effect.Effect<{ + identifier: string + whenToUse: string + systemPrompt: string + }> + } - const skillDirs = await Skill.dirs() - const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = Permission.fromConfig({ - "*": "allow", - doom_loop: "ask", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - // mirrors github.com/github/gitignore Node.gitignore pattern for .env files - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - }) - const user = Permission.fromConfig(cfg.permission ?? {}) + type State = Omit - const result: Record = { - build: { - name: "build", - description: "The default agent. Executes tools based on configured permissions.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_enter: "allow", - }), - user, - ), - mode: "primary", - native: true, - }, - plan: { - name: "plan", - description: "Plan mode. Disallows all edit tools.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", - }, - }), - user, - ), - mode: "primary", - native: true, - }, - general: { - name: "general", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - todoread: "deny", - todowrite: "deny", - }), - user, - ), - options: {}, - mode: "subagent", - native: true, - }, - explore: { - name: "explore", - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - grep: "allow", - glob: "allow", - list: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - read: "allow", + export class Service extends ServiceMap.Service()("@opencode/Agent") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = () => Effect.promise(() => Config.get()) + const auth = yield* Auth.Service + + const state = yield* InstanceState.make( + Effect.fn("Agent.state")(function* (ctx) { + const cfg = yield* config() + const skillDirs = yield* Effect.promise(() => Skill.dirs()) + const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] + + const defaults = Permission.fromConfig({ + "*": "allow", + doom_loop: "ask", external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, - }), - user, - ), - description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: PROMPT_EXPLORE, - options: {}, - mode: "subagent", - native: true, - }, - compaction: { - name: "compaction", - mode: "primary", - native: true, - hidden: true, - prompt: PROMPT_COMPACTION, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - options: {}, - }, - title: { - name: "title", - mode: "primary", - options: {}, - native: true, - hidden: true, - temperature: 0.5, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_TITLE, - }, - summary: { - name: "summary", - mode: "primary", - options: {}, - native: true, - hidden: true, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_SUMMARY, - }, - } + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + // mirrors github.com/github/gitignore Node.gitignore pattern for .env files + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - if (value.disable) { - delete result[key] - continue - } - let item = result[key] - if (!item) - item = result[key] = { - name: key, - mode: "all", - permission: Permission.merge(defaults, user), - options: {}, - native: false, - } - if (value.model) item.model = Provider.parseModel(value.model) - item.variant = value.variant ?? item.variant - item.prompt = value.prompt ?? item.prompt - item.description = value.description ?? item.description - item.temperature = value.temperature ?? item.temperature - item.topP = value.top_p ?? item.topP - item.mode = value.mode ?? item.mode - item.color = value.color ?? item.color - item.hidden = value.hidden ?? item.hidden - item.name = value.name ?? item.name - item.steps = value.steps ?? item.steps - item.options = mergeDeep(item.options, value.options ?? {}) - item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) - } + const user = Permission.fromConfig(cfg.permission ?? {}) - // Ensure Truncate.GLOB is allowed unless explicitly configured - for (const name in result) { - const agent = result[name] - const explicit = agent.permission.some((r) => { - if (r.permission !== "external_directory") return false - if (r.action !== "deny") return false - return r.pattern === Truncate.GLOB - }) - if (explicit) continue + const agents: Record = { + build: { + name: "build", + description: "The default agent. Executes tools based on configured permissions.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + }, + plan: { + name: "plan", + description: "Plan mode. Disallows all edit tools.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: + "allow", + }, + }), + user, + ), + mode: "primary", + native: true, + }, + general: { + name: "general", + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + todoread: "deny", + todowrite: "deny", + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + explore: { + name: "explore", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }), + user, + ), + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + prompt: PROMPT_EXPLORE, + options: {}, + mode: "subagent", + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + options: {}, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0.5, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_TITLE, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_SUMMARY, + }, + } - result[name].permission = Permission.merge( - result[name].permission, - Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete agents[key] + continue + } + let item = agents[key] + if (!item) + item = agents[key] = { + name: key, + mode: "all", + permission: Permission.merge(defaults, user), + options: {}, + native: false, + } + if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden + item.name = value.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + } + + // Ensure Truncate.GLOB is allowed unless explicitly configured + for (const name in agents) { + const agent = agents[name] + const explicit = agent.permission.some((r) => { + if (r.permission !== "external_directory") return false + if (r.action !== "deny") return false + return r.pattern === Truncate.GLOB + }) + if (explicit) continue + + agents[name].permission = Permission.merge( + agents[name].permission, + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + ) + } + + const get = Effect.fnUntraced(function* (agent: string) { + return agents[agent] + }) + + const list = Effect.fnUntraced(function* () { + const cfg = yield* config() + return pipe( + agents, + values(), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => x.name, "asc"], + ), + ) + }) + + const defaultAgent = Effect.fnUntraced(function* () { + const c = yield* config() + if (c.default_agent) { + const agent = agents[c.default_agent] + if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) + if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) + if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) + return agent.name + } + const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + if (!visible) throw new Error("no primary visible agent found") + return visible.name + }) + + return { + get, + list, + defaultAgent, + } satisfies State + }), ) - } - return result - }) + return Service.of({ + get: Effect.fn("Agent.get")(function* (agent: string) { + return yield* InstanceState.useEffect(state, (s) => s.get(agent)) + }), + list: Effect.fn("Agent.list")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.list()) + }), + defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) + }), + generate: Effect.fn("Agent.generate")(function* (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) { + const cfg = yield* config() + const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel())) + const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID)) + const language = yield* Effect.promise(() => Provider.getLanguage(resolved)) + + const system = [PROMPT_GENERATE] + yield* Effect.promise(() => + Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }), + ) + const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + + const params = { + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + metadata: { + userId: cfg.username ?? "unknown", + }, + }, + temperature: 0.3, + messages: [ + ...system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + ), + { + role: "user", + content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: language, + schema: z.object({ + identifier: z.string(), + whenToUse: z.string(), + systemPrompt: z.string(), + }), + } satisfies Parameters[0] + + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + if (model.providerID === "openai" && authInfo?.type === "oauth") { + return yield* Effect.promise(async () => { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(resolved, { + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + }) + } + + return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export async function get(agent: string) { - return state().then((x) => x[agent]) + return runPromise((svc) => svc.get(agent)) } export async function list() { - const cfg = await Config.get() - return pipe( - await state(), - values(), - sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], - [(x) => x.name, "asc"], - ), - ) + return runPromise((svc) => svc.list()) } export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - const agent = agents[cfg.default_agent] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name + return runPromise((svc) => svc.defaultAgent()) } export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { - const cfg = await Config.get() - const defaultModel = input.model ?? (await Provider.defaultModel()) - const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) - const language = await Provider.getLanguage(model) - - const system = [PROMPT_GENERATE] - await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) - const existing = await list() - - const params = { - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - }, - }, - temperature: 0.3, - messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), - { - role: "user", - content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, - }, - ], - model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), - } satisfies Parameters[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, { - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - } - - const result = await generateObject(params) - return result.object + return runPromise((svc) => svc.generate(input)) } } From 98b3340ceeb6928d0d57898d02665d763ef1ea9c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:23:41 -0500 Subject: [PATCH 093/108] fix(app): more startup efficiency (#18985) --- packages/app/src/components/prompt-input.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 308 +++++++++++------- packages/app/src/context/settings.tsx | 7 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/pages/home.tsx | 8 + packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 + packages/app/vite.js | 12 + 8 files changed, 240 insertions(+), 128 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c795ab471c..47be3abcb3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,6 +31,47 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -38,45 +79,54 @@ export async function bootstrapGlobal(input: { formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -119,95 +169,113 @@ export async function bootstrapDirectory(input: { } if (loading) input.setStore("status", "partial") - const results = await Promise.allSettled([ - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), - retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - input.loadSessions(input.directory), - retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), - retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - retry(() => - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - retry(() => - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - ]) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] - const errors = results - .filter((item): item is PromiseRejectedResult => item.status === "rejected") - .map((item) => item.reason) - if (errors.length > 0) { - console.error("Failed to bootstrap instance", errors[0]) + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errors[0], input.translate), + description: formatServerError(errs[0], input.translate), }) - return } - if (loading) input.setStore("status", "complete") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } + + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 247d36dd36..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -118,8 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 19dcba58ee..722a688bba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1179,8 +1179,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1635,6 +1633,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1706,7 +1707,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..f65a68a1cb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ] From 9838f56a6f8598ae5d9b587067e4de20adfb303d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:35:15 -0500 Subject: [PATCH 094/108] fix(app): sidebar ux --- packages/app/src/pages/layout.tsx | 54 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d01c7d3ceb..731f0fe5b2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1818,6 +1818,9 @@ export default function Layout(props: ParentProps) { document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) + const side = createMemo(() => Math.max(layout.sidebar.width(), 244)) + const panel = createMemo(() => Math.max(side() - 64, 0)) + const loadedSessionDirs = new Set() createEffect( @@ -2094,7 +2097,7 @@ export default function Layout(props: ParentProps) { "max-w-full overflow-hidden": panelProps.mobile, }} style={{ - width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, + width: panelProps.mobile ? undefined : `${panel()}px`, }} > @@ -2384,7 +2389,7 @@ export default function Layout(props: ParentProps) { "absolute inset-y-0 left-0": true, "z-10": true, }} - style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} + style={{ width: `${side()}px` }} ref={(el) => { setState("nav", el) }} @@ -2399,24 +2404,29 @@ export default function Layout(props: ParentProps) { }} >
{sidebarContent()}
- -
setState("sizing", true)}> - { - setState("sizing", true) - if (sizet !== undefined) clearTimeout(sizet) - sizet = window.setTimeout(() => setState("sizing", false), 120) - layout.sidebar.resize(w) - }} - /> -
-
+ + + +