From ae17b416b8da910f43b8dca5356de41ef72d2685 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Mon, 13 Apr 2026 04:37:57 +0300 Subject: [PATCH 001/154] fix(cli): auth login now asks for api key in handlePluginAuth (#21641) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/cli/cmd/providers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 1ab0ecc7bc..52da441904 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -148,6 +148,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (method.type === "api") { + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + if (method.authorize) { const result = await method.authorize(inputs) if (result.type === "failed") { @@ -157,7 +163,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const saveProvider = result.provider ?? provider await Auth.set(saveProvider, { type: "api", - key: result.key, + key: result.key ?? key, }) prompts.log.success("Login successful") } From 26d35583c5b9e75b7986f332cfc68813ea3a6e06 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 13 Apr 2026 09:39:53 +0800 Subject: [PATCH 002/154] sdk: throw error if response has text/html content type (#21289) --- packages/sdk/js/src/v2/client.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 67fe1de32f..2d71d8446d 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -77,6 +77,12 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp workspace: config?.experimental_workspaceID, }), ) - const result = new OpencodeClient({ client }) - return result + client.interceptors.response.use((response) => { + const contentType = response.headers.get("content-type") + if (contentType === "text/html") + throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)") + + return response + }) + return new OpencodeClient({ client }) } From a915fe74be24d4df9caf4c5b0e0f60133367b00d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:39:06 -0500 Subject: [PATCH 003/154] tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) --- packages/opencode/src/session/index.ts | 16 ++-- .../opencode/test/session/compaction.test.ts | 96 +++++++++++++++++-- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3d49035881..b43b724a00 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" -import { type ProviderMetadata } from "ai" +import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "../flag/flag" import { Installation } from "../installation" @@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@/global" -import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { Effect, Layer, Option, Context } from "effect" import { makeRuntime } from "@/effect/run-service" @@ -240,7 +239,7 @@ export namespace Session { export const getUsage = (input: { model: Provider.Model - usage: LanguageModelV2Usage + usage: LanguageModelUsage metadata?: ProviderMetadata }) => { const safe = (value: number) => { @@ -249,11 +248,14 @@ export namespace Session { } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) - const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) + const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) - const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0) + const cacheReadInputTokens = safe( + input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, + ) const cacheWriteInputTokens = safe( - (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + (input.usage.inputTokenDetails?.cacheWriteTokens ?? + input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // google-vertex-anthropic returns metadata under "vertex" key // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? @@ -274,7 +276,7 @@ export namespace Session { const tokens = { total, input: adjustedInputTokens, - output: outputTokens - reasoningTokens, + output: safe(outputTokens - reasoningTokens), reasoning: reasoningTokens, cache: { write: cacheWriteInputTokens, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 76a83c34da..61b47df34a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1005,6 +1005,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, }) @@ -1023,7 +1032,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - cachedInputTokens: 200, + inputTokenDetails: { + noCacheTokens: 800, + cacheReadTokens: 200, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, }) @@ -1039,6 +1056,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, metadata: { anthropic: { @@ -1059,7 +1085,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - cachedInputTokens: 200, + inputTokenDetails: { + noCacheTokens: 800, + cacheReadTokens: 200, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, metadata: { anthropic: {}, @@ -1078,7 +1112,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - reasoningTokens: 100, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: 400, + reasoningTokens: 100, + }, }, }) @@ -1104,7 +1146,15 @@ describe("session.getUsage", () => { inputTokens: 0, outputTokens: 1_000_000, totalTokens: 1_000_000, - reasoningTokens: 250_000, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: 750_000, + reasoningTokens: 250_000, + }, }, }) @@ -1121,6 +1171,15 @@ describe("session.getUsage", () => { inputTokens: 0, outputTokens: 0, totalTokens: 0, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, }) @@ -1148,6 +1207,15 @@ describe("session.getUsage", () => { inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, }) @@ -1163,7 +1231,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - cachedInputTokens: 200, + inputTokenDetails: { + noCacheTokens: 800, + cacheReadTokens: 200, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, } if (npm === "@ai-sdk/amazon-bedrock") { const result = Session.getUsage({ @@ -1214,7 +1290,15 @@ describe("session.getUsage", () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - cachedInputTokens: 200, + inputTokenDetails: { + noCacheTokens: 800, + cacheReadTokens: 200, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, metadata: { vertex: { From 7230cd26838a133e93699497d2d27eb59ccf8460 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:08:07 -0500 Subject: [PATCH 004/154] feat: add alibaba pkg and cache support (#22248) --- bun.lock | 5 +++++ packages/opencode/package.json | 1 + packages/opencode/src/provider/provider.ts | 2 ++ packages/opencode/src/provider/transform.ts | 3 +++ 4 files changed, 11 insertions(+) diff --git a/bun.lock b/bun.lock index 88d9635491..7ff8a3072f 100644 --- a/bun.lock +++ b/bun.lock @@ -319,6 +319,7 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", + "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/anthropic": "3.0.67", "@ai-sdk/azure": "3.0.49", @@ -707,6 +708,8 @@ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], + "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], @@ -5003,6 +5006,8 @@ "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 60d63f8403..f5cc0e0a9b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -76,6 +76,7 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", + "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/anthropic": "3.0.67", "@ai-sdk/azure": "3.0.49", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e401a067c7..ef822739dd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -46,6 +46,7 @@ import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { createVenice } from "venice-ai-sdk-provider" +import { createAlibaba } from "@ai-sdk/alibaba" import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION, @@ -145,6 +146,7 @@ export namespace Provider { "@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/perplexity": createPerplexity, "@ai-sdk/vercel": createVercel, + "@ai-sdk/alibaba": createAlibaba, "gitlab-ai-provider": createGitLab, "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, "venice-ai-sdk-provider": createVenice, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index dea8cf936a..99aaad6c9c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -209,6 +209,9 @@ export namespace ProviderTransform { copilot: { copilot_cache_control: { type: "ephemeral" }, }, + alibaba: { + cacheControl: { type: "ephemeral" }, + }, } for (const msg of unique([...system, ...final])) { From 0b4fe14b0a0ab0b38d890706d4071a407d16674f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:39:12 -0500 Subject: [PATCH 005/154] fix: forgot to put alibaba case in last commit (#22249) --- packages/opencode/src/provider/transform.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 99aaad6c9c..8cdc48e243 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -288,7 +288,8 @@ export namespace ProviderTransform { model.api.id.includes("claude") || model.id.includes("anthropic") || model.id.includes("claude") || - model.api.npm === "@ai-sdk/anthropic") && + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/alibaba") && model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) From 34f5bdbc9967fb67ef75c645443c638ce852aa09 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 13 Apr 2026 13:55:33 +0800 Subject: [PATCH 006/154] app: fix scroll to bottom light mode style (#22250) --- packages/app/src/pages/session/message-timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index fe6447c2e8..eac425fa6c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -642,10 +642,10 @@ export function MessageTimeline(props: { onClick={props.onResumeScroll} >
From a6b9f0dac1a67ab669543ac946dcaace2031e2ec Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 13 Apr 2026 13:58:35 +0800 Subject: [PATCH 007/154] app: align workspace load more button (#22251) --- packages/app/src/pages/layout/sidebar-workspace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 68e36ff77a..878b6e5fa2 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
From c98f61638535c9cc57a2b710decc780f7289fc2f Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 15:29:36 +0800 Subject: [PATCH 124/154] ui: update accordion styles and session review component (#22582) --- packages/ui/src/styles/base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index b5604ad619..a032f9ea2d 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -82,7 +82,7 @@ a { cursor: default; } -*[data-tauri-drag-region] { +#root:not([aria-hidden]) *[data-tauri-drag-region] { app-region: drag; } From d7718d41d465cc1e84bc4d6c2e81af8baf46a23e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 17:21:04 +0800 Subject: [PATCH 125/154] refactor(electron): update store configuration (#22597) --- packages/desktop-electron/src/main/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index cf2d25b110..709e820e25 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -7,7 +7,7 @@ const cache = new Map() export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached - const next = new Store({ name, fileExtension: "" }) + const next = new Store({ name, fileExtension: "", accessPropertiesByDotNotation: false }) cache.set(name, next) return next } From 405b0b037c7597448e6b36438425042c8b0cf772 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 15 Apr 2026 14:29:09 +0200 Subject: [PATCH 126/154] handle non-throwing requests (#22604) --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 498db99a1b..772b5d9a0c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -336,7 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "lsp.updated": { const workspace = project.workspace.current() - sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!)) + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? [])) break } @@ -419,14 +419,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))), - sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))), + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), + sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), sdk.client.experimental.resource .list({ workspace }) .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), + sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))), sdk.client.session.status({ workspace }).then((x) => { - setStore("session_status", reconcile(x.data!)) + setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), From 004a9284afb09b31105cb2bc26d993af0726585b Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 15 Apr 2026 09:12:09 -0400 Subject: [PATCH 127/154] sync --- packages/web/src/content/docs/ar/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/bs/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/da/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/de/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/es/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/fr/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/it/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/ja/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/ko/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/nb/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/pl/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/pt-br/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/ru/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/th/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/tr/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/zh-cn/go.mdx | 16 +++++++++++----- packages/web/src/content/docs/zh-tw/go.mdx | 16 +++++++++++----- 17 files changed, 187 insertions(+), 85 deletions(-) diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index fe95dc3dd2..655749eeae 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -79,11 +79,17 @@ OpenCode Go حاليًا في المرحلة التجريبية. يوضح الجدول أدناه عددًا تقديريًا للطلبات بناءً على أنماط استخدام Go المعتادة: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| الطلبات لكل 5 ساعات | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| الطلبات في الأسبوع | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| الطلبات في الشهر | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | الطلبات لكل 5 ساعات | الطلبات في الأسبوع | الطلبات في الشهر | +| ------------ | ------------------- | ------------------ | ---------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة: diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index f777d8d99e..3dabedf6a3 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -89,11 +89,17 @@ Ograničenja su definisana u dolarskoj vrijednosti. To znači da vaš stvarni br Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca korištenja Go pretplate: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------ | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| zahtjeva na 5 sati | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| zahtjeva sedmično | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| zahtjeva mjesečno | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | zahtjeva na 5 sati | zahtjeva sedmično | zahtjeva mjesečno | +| ------------ | ------------------ | ----------------- | ----------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 5254c13431..54f17bd77b 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -89,11 +89,17 @@ Grænserne er defineret i dollarværdi. Det betyder, at dit faktiske antal anmod Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-forbrugsmønstre: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ----------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| anmodninger pr. 5 timer | 880 | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 | 3,300 | 10,200 | -| anmodninger pr. uge | 2.150 | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 | 8,200 | 25,200 | -| anmodninger pr. måned | 4.300 | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 | 20,500 | 50,500 | +| Model | anmodninger pr. 5 timer | anmodninger pr. uge | anmodninger pr. måned | +| ------------ | ----------------------- | ------------------- | --------------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index f739ef1933..0c5ec931ae 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -81,11 +81,17 @@ Limits sind in Dollarwerten definiert. Das bedeutet, dass die tatsächliche Anza Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf typischen Go-Nutzungsmustern: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ---------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| Anfragen pro 5 Stunden | 880 | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 | 3,300 | 10,200 | -| Anfragen pro Woche | 2.150 | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 | 8,200 | 25,200 | -| Anfragen pro Monat | 4.300 | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 | 20,500 | 50,500 | +| Model | Anfragen pro 5 Stunden | Anfragen pro Woche | Anfragen pro Monat | +| ------------ | ---------------------- | ------------------ | ------------------ | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index d8a7a66243..86ddbe81e8 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -89,11 +89,17 @@ Los límites se definen en valor en dólares. Esto significa que tu cantidad rea La siguiente tabla proporciona una cantidad estimada de peticiones basada en los patrones típicos de uso de Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ---------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| peticiones por 5 horas | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| peticiones por semana | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| peticiones por mes | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | peticiones por 5 horas | peticiones por semana | peticiones por mes | +| ------------ | ---------------------- | --------------------- | ------------------ | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Las estimaciones se basan en los patrones de peticiones promedio observados: diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index b5e3b76576..b10cf9141e 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -79,11 +79,17 @@ Les limites sont définies en valeur monétaire (dollars). Cela signifie que vot Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur des modèles d'utilisation typiques de Go : -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| --------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| requêtes par 5 heures | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| requêtes par semaine | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| requêtes par mois | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | requêtes par 5 heures | requêtes par semaine | requêtes par mois | +| ------------ | --------------------- | -------------------- | ----------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Les estimations sont basées sur les modèles de requêtes moyens observés : diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 92fc4b1e4d..f90dce5094 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -87,11 +87,17 @@ I limiti sono definiti in valore in dollari. Questo significa che il conteggio e La tabella seguente fornisce una stima del conteggio delle richieste in base a pattern di utilizzo tipici di Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| --------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| richieste ogni 5 ore | 880 | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 | 3,300 | 10,200 | -| richieste a settimana | 2.150 | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 | 8,200 | 25,200 | -| richieste al mese | 4.300 | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 | 20,500 | 50,500 | +| Model | richieste ogni 5 ore | richieste a settimana | richieste al mese | +| ------------ | -------------------- | --------------------- | ----------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Le stime si basano sui pattern medi di richieste osservati: diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 25f5811ea4..01f1d6390b 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -79,11 +79,17 @@ OpenCode Goには以下の制限が含まれています: 以下の表は、一般的なGoの利用パターンに基づいた推定リクエスト数を示しています: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| 5時間あたりのリクエスト数 | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| 週間リクエスト数 | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| 月間リクエスト数 | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | 5時間あたりのリクエスト数 | 週間リクエスト数 | 月間リクエスト数 | +| ------------ | ------------------------- | ---------------- | ---------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | 推定値は、観測された平均的なリクエストパターンに基づいています: diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 7fee280b00..9af7541e10 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -79,11 +79,17 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. 아래 표는 일반적인 Go 사용 패턴을 기준으로 한 예상 요청 횟수를 보여줍니다. -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ----------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| 5시간당 요청 횟수 | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| 주간 요청 횟수 | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| 월간 요청 횟수 | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | 5시간당 요청 횟수 | 주간 요청 횟수 | 월간 요청 횟수 | +| ------------ | ----------------- | -------------- | -------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | 예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 6b71f08ca9..1638e5e489 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -89,11 +89,17 @@ Grensene er definert i dollarverdi. Dette betyr at ditt faktiske antall forespø Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksmønstre for Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------------ | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| forespørsler per 5 timer | 880 | 1 150 | 1 850 | 1 290 | 2 150 | 14 000 | 20 000 | 3,300 | 10,200 | -| forespørsler per uke | 2 150 | 2 880 | 4 630 | 3 225 | 5 450 | 35 000 | 50 000 | 8,200 | 25,200 | -| forespørsler per måned | 4 300 | 5 750 | 9 250 | 6 450 | 10 900 | 70 000 | 100 000 | 20,500 | 50,500 | +| Model | forespørsler per 5 timer | forespørsler per uke | forespørsler per måned | +| ------------ | ------------------------ | -------------------- | ---------------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 8b7520aed1..c5f17672ae 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -83,11 +83,17 @@ Limity są zdefiniowane w wartości w dolarach. Oznacza to, że rzeczywista licz Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych wzorców korzystania z Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| żądania na 5 godzin | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| żądania na tydzień | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| żądania na miesiąc | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | żądania na 5 godzin | żądania na tydzień | żądania na miesiąc | +| ------------ | ------------------- | ------------------ | ------------------ | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 840254c5cb..48afe36d90 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -89,11 +89,17 @@ Os limites são definidos em valor em dólares. Isso significa que a sua contage A tabela abaixo fornece uma contagem estimada de requisições com base nos padrões típicos de uso do Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ----------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| requisições por 5 horas | 880 | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 | 3,300 | 10,200 | -| requisições por semana | 2.150 | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 | 8,200 | 25,200 | -| requisições por mês | 4.300 | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 | 20,500 | 50,500 | +| Model | requisições por 5 horas | requisições por semana | requisições por mês | +| ------------ | ----------------------- | ---------------------- | ------------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | As estimativas baseiam-se nos padrões médios de requisições observados: diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index fae41dc262..dacaf65273 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -89,11 +89,17 @@ OpenCode Go включает следующие лимиты: В таблице ниже приведено примерное количество запросов на основе типичных сценариев использования Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| запросов за 5 часов | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| запросов в неделю | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| запросов в месяц | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | запросов за 5 часов | запросов в неделю | запросов в месяц | +| ------------ | ------------------- | ----------------- | ---------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Оценки основаны на наблюдаемых средних показателях запросов: diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 94fae4883d..aa26f7dcf1 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -79,11 +79,17 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: ตารางด้านล่างแสดงจำนวน request โดยประมาณตามรูปแบบการใช้งานปกติของ Go: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ---------------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| requests ต่อ 5 ชั่วโมง | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| requests ต่อสัปดาห์ | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| requests ต่อเดือน | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | requests ต่อ 5 ชั่วโมง | requests ต่อสัปดาห์ | requests ต่อเดือน | +| ------------ | ---------------------- | ------------------- | ----------------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index 31d72f28d5..b085f6d060 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -79,11 +79,17 @@ Limitler dolar değeri üzerinden belirlenmiştir. Bu, gerçek istek sayınızı Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek sayısı sunmaktadır: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| ------------------ | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| 5 saatte bir istek | 880 | 1.150 | 1.850 | 1.290 | 2.150 | 14.000 | 20.000 | 3,300 | 10,200 | -| haftalık istek | 2.150 | 2.880 | 4.630 | 3.225 | 5.450 | 35.000 | 50.000 | 8,200 | 25,200 | -| aylık istek | 4.300 | 5.750 | 9.250 | 6.450 | 10.900 | 70.000 | 100.000 | 20,500 | 50,500 | +| Model | 5 saatte bir istek | haftalık istek | aylık istek | +| ------------ | ------------------ | -------------- | ----------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 3bf1bc08db..df74f35ec1 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -79,11 +79,17 @@ OpenCode Go 包含以下限制: 下表提供了基于典型 Go 使用模式的预估请求数: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| --------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| 每 5 小时请求数 | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| 每周请求数 | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| 每月请求数 | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | 每 5 小时请求数 | 每周请求数 | 每月请求数 | +| ------------ | --------------- | ---------- | ---------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | 预估值基于观察到的平均请求模式: diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index ad34192c60..9c12710a07 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -79,11 +79,17 @@ OpenCode Go 包含以下限制: 下表提供了基於典型 Go 使用模式的預估請求次數: -| | GLM-5.1 | GLM-5 | Kimi K2.5 | MiMo-V2-Pro | MiMo-V2-Omni | MiniMax M2.7 | MiniMax M2.5 | Qwen3.6 Plus | Qwen3.5 Plus | -| --------------- | ------- | ----- | --------- | ----------- | ------------ | ------------ | ------------ | ------------ | ------------ | -| 每 5 小時請求數 | 880 | 1,150 | 1,850 | 1,290 | 2,150 | 14,000 | 20,000 | 3,300 | 10,200 | -| 每週請求數 | 2,150 | 2,880 | 4,630 | 3,225 | 5,450 | 35,000 | 50,000 | 8,200 | 25,200 | -| 每月請求數 | 4,300 | 5,750 | 9,250 | 6,450 | 10,900 | 70,000 | 100,000 | 20,500 | 50,500 | +| Model | 每 5 小時請求數 | 每週請求數 | 每月請求數 | +| ------------ | --------------- | ---------- | ---------- | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| GLM-5 | 1,150 | 2,880 | 5,750 | +| Kimi K2.5 | 1,850 | 4,630 | 9,250 | +| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | +| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| MiniMax M2.5 | 6,300 | 15,900 | 31,800 | +| Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | 預估值是基於觀察到的平均請求模式: From 47af00b2452ef7374cdda8769910799938d1303c Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 15 Apr 2026 09:19:26 -0400 Subject: [PATCH 128/154] zen: better error --- .../app/src/routes/zen/util/handler.ts | 25 ++++++++++--------- packages/console/core/src/model.ts | 1 + 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 46d8435225..58df618094 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -222,22 +222,23 @@ export async function handler( logger.debug("STATUS: " + res.status + " " + res.statusText) // Handle non-streaming response - if (!isStream) { + if (!isStream || res.status === 429) { const json = await res.json() - const usageInfo = providerInfo.normalizeUsage(json.usage) - const costInfo = calculateCost(modelInfo, usageInfo) - await trialLimiter?.track(usageInfo) await rateLimiter?.track() - await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) - await reload(billingSource, authInfo, costInfo) + if (json.usage) { + const usageInfo = providerInfo.normalizeUsage(json.usage) + const costInfo = calculateCost(modelInfo, usageInfo) + await trialLimiter?.track(usageInfo) + await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) + await reload(billingSource, authInfo, costInfo) + json.cost = calculateOccurredCost(billingSource, costInfo) + } + if (json.error?.message) { + json.error.message = `Error from provider${providerInfo.displayName ? ` (${providerInfo.displayName})` : ""}: ${json.error.message}` + } const responseConverter = createResponseConverter(providerInfo.format, opts.format) - const body = JSON.stringify( - responseConverter({ - ...json, - cost: calculateOccurredCost(billingSource, costInfo), - }), - ) + const body = JSON.stringify(responseConverter(json)) logger.metric({ response_length: body.length }) logger.debug("RESPONSE: " + body) dataDumper?.provideResponse(body) diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index b4149373fe..3d614d3034 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -44,6 +44,7 @@ export namespace ZenData { }) const ProviderSchema = z.object({ + displayName: z.string().optional(), api: z.string(), apiKey: z.union([z.string(), z.record(z.string(), z.string())]), format: FormatSchema.optional(), From af20191d1cd60a7f4a421ad81eca5053f7deace1 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 15 Apr 2026 10:18:48 -0400 Subject: [PATCH 129/154] feat(core): sync routes, refactor proxy, session restore, and more syncing (#22518) --- .../opencode/src/control-plane/workspace.ts | 260 ++++++++++++-- .../opencode/src/server/instance/index.ts | 2 + .../src/server/instance/middleware.ts | 66 ++-- packages/opencode/src/server/instance/sync.ts | 118 +++++++ .../opencode/src/server/instance/workspace.ts | 71 +++- packages/opencode/src/server/middleware.ts | 2 +- packages/opencode/src/server/proxy.ts | 55 ++- packages/opencode/src/sync/index.ts | 19 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 157 +++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 127 +++++++ packages/sdk/openapi.json | 320 ++++++++++++++++++ 11 files changed, 1133 insertions(+), 64 deletions(-) create mode 100644 packages/opencode/src/server/instance/sync.ts diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f330e07b7a..78f3d770eb 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,11 +1,13 @@ import z from "zod" import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" -import { Database, eq } from "@/storage/db" +import { Database, asc, eq } from "@/storage/db" import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { SyncEvent } from "@/sync" +import { EventTable } from "@/sync/event.sql" +import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" @@ -15,6 +17,11 @@ import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" +import { Session } from "@/session" +import { SessionTable } from "@/session/session.sql" +import { SessionID } from "@/session/schema" +import { errorData } from "@/util/error" +import { AppRuntime } from "@/effect/app-runtime" export namespace Workspace { export const Info = WorkspaceInfo.meta({ @@ -29,6 +36,13 @@ export namespace Workspace { }) export type ConnectionStatus = z.infer + const Restore = z.object({ + workspaceID: WorkspaceID.zod, + sessionID: SessionID.zod, + total: z.number().int().min(0), + step: z.number().int().min(0), + }) + export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -42,6 +56,7 @@ export namespace Workspace { message: z.string(), }), ), + Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -102,11 +117,170 @@ export namespace Workspace { return info }) + const SessionRestoreInput = z.object({ + workspaceID: WorkspaceID.zod, + sessionID: SessionID.zod, + }) + + export const sessionRestore = fn(SessionRestoreInput, async (input) => { + log.info("session restore requested", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + }) + try { + const space = await get(input.workspaceID) + if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`) + + const adaptor = await getAdaptor(space.projectID, space.type) + const target = await adaptor.target(space) + + // Need to switch the workspace of the session + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + const rows = Database.use((db) => + db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all(), + ) + if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`) + + const all = rows + + const size = 10 + const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size)) + const total = sets.length + log.info("session restore prepared", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + workspaceType: space.type, + directory: space.directory, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + events: all.length, + batches: total, + first: all[0]?.seq, + last: all.at(-1)?.seq, + }) + GlobalBus.emit("event", { + directory: "global", + workspace: input.workspaceID, + payload: { + type: Event.Restore.type, + properties: { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + total, + step: 0, + }, + }, + }) + for (const [i, events] of sets.entries()) { + log.info("session restore batch starting", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + }) + if (target.type === "local") { + SyncEvent.replayAll(events) + log.info("session restore batch replayed locally", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + events: events.length, + }) + } else { + const url = route(target.url, "/sync/replay") + const headers = new Headers(target.headers) + headers.set("content-type", "application/json") + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + directory: space.directory ?? "", + events, + }), + }) + if (!res.ok) { + const body = await res.text() + log.error("session restore batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + body, + }) + throw new Error( + `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, + ) + } + log.info("session restore batch posted", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + }) + } + GlobalBus.emit("event", { + directory: "global", + workspace: input.workspaceID, + payload: { + type: Event.Restore.type, + properties: { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + total, + step: i + 1, + }, + }, + }) + } + + log.info("session restore complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + batches: total, + }) + + return { + total, + } + } catch (err) { + log.error("session restore failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }) + throw err + } + }) + export function list(project: Project.Info) { const rows = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), ) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + for (const space of spaces) startSync(space) return spaces } @@ -120,13 +294,25 @@ export namespace Workspace { }) export const remove = fn(WorkspaceID.zod, async (id) => { + const sessions = Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + ) + for (const session of sessions) { + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id))) + } + const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (row) { stopSync(id) const info = fromRow(row) - const adaptor = await getAdaptor(info.projectID, row.type) - adaptor.remove(info) + try { + const adaptor = await getAdaptor(info.projectID, row.type) + await adaptor.remove(info) + } catch (err) { + log.error("adaptor not available when removing workspace", { type: row.type }) + } Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) return info } @@ -156,51 +342,81 @@ export namespace Workspace { const log = Log.create({ service: "workspace-sync" }) - async function workspaceEventLoop(space: Info, signal: AbortSignal) { - log.info("starting sync: " + space.id) + function route(url: string | URL, path: string) { + const next = new URL(url) + next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` + next.search = "" + next.hash = "" + return next + } + async function syncWorkspace(space: Info, signal: AbortSignal) { while (!signal.aborted) { - log.info("connecting to sync: " + space.id) + log.info("connecting to global sync", { workspace: space.name }) - setStatus(space.id, "connecting") const adaptor = await getAdaptor(space.projectID, space.type) const target = await adaptor.target(space) if (target.type === "local") return - const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { - setStatus(space.id, "error", String(err)) + const res = await fetch(route(target.url, "/global/event"), { + method: "GET", + headers: target.headers, + signal, + }).catch((err: unknown) => { + setStatus(space.id, "error") + + log.info("failed to connect to global sync", { + workspace: space.name, + error: err, + }) return undefined }) - if (!res || !res.ok || !res.body) { - log.info("failed to connect to sync: " + res?.status) - setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") + if (!res || !res.ok || !res.body) { + log.info("failed to connect to global sync", { workspace: space.name }) + setStatus(space.id, "error") await sleep(1000) continue } - setStatus(space.id, "connected") - await parseSSE(res.body, signal, (evt) => { - const event = evt as SyncEvent.SerializedEvent + log.info("global sync connected", { workspace: space.name }) + setStatus(space.id, "connected") + + await parseSSE(res.body, signal, (evt: any) => { try { - if (!event.type.startsWith("server.")) { - SyncEvent.replay(event) + if (!("payload" in evt)) return + + if (evt.payload.type === "sync") { + // This name -> type is temporary + SyncEvent.replay({ ...evt.payload, type: evt.payload.name } as SyncEvent.SerializedEvent) } + + GlobalBus.emit("event", { + directory: evt.directory, + project: evt.project, + workspace: space.id, + payload: evt.payload, + }) } catch (err) { - log.warn("failed to replay sync event", { + log.info("failed to replay global event", { workspaceID: space.id, error: err, }) } }) + + log.info("disconnected from global sync: " + space.id) setStatus(space.id, "disconnected") - log.info("disconnected to sync: " + space.id) - await sleep(250) + + // TODO: Implement exponential backoff + await sleep(1000) } } function startSync(space: Info) { + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return + if (space.type === "worktree") { void Filesystem.exists(space.directory!).then((exists) => { setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") @@ -213,9 +429,9 @@ export namespace Workspace { aborts.set(space.id, abort) setStatus(space.id, "disconnected") - void workspaceEventLoop(space, abort.signal).catch((error) => { + void syncWorkspace(space, abort.signal).catch((error) => { setStatus(space.id, "error", String(error)) - log.warn("workspace sync listener failed", { + log.warn("workspace listener failed", { workspaceID: space.id, error, }) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 86a18dc673..4a03b7b29c 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -23,6 +23,7 @@ import { ConfigRoutes } from "./config" import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" +import { SyncRoutes } from "./sync" import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" @@ -37,6 +38,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) + .route("/sync", SyncRoutes()) .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 9155ad451b..824c265efe 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -11,9 +11,12 @@ import { Session } from "@/session" import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { AppRuntime } from "@/effect/app-runtime" +import { Log } from "@/util/log" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } +const OPENCODE_WORKSPACE = process.env.OPENCODE_WORKSPACE + const RULES: Array = [ { path: "/session/status", action: "forward" }, { method: "GET", path: "/session", action: "local" }, @@ -46,6 +49,8 @@ async function getSessionWorkspace(url: URL) { } export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { + const log = Log.create({ service: "workspace-router" }) + return async (c, next) => { const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const directory = Filesystem.resolve( @@ -63,8 +68,22 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const sessionWorkspaceID = await getSessionWorkspace(url) const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - // If no workspace is provided we use the project - if (!workspaceID) { + if (!workspaceID || url.pathname.startsWith("/console") || OPENCODE_WORKSPACE) { + if (OPENCODE_WORKSPACE) { + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(OPENCODE_WORKSPACE), + async fn() { + return Instance.provide({ + directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + async fn() { + return next() + }, + }) + }, + }) + } + return Instance.provide({ directory, init: () => AppRuntime.runPromise(InstanceBootstrap), @@ -77,16 +96,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) if (!workspace) { - // Special-case deleting a session in case user's data in a - // weird state. Allow them to forcefully delete a synced session - // even if the remote workspace is not in their data. - // - // The lets the `DELETE /session/:id` endpoint through and we've - // made sure that it will run without an instance - if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") { - return next() - } - return new Response(`Workspace not found: ${workspaceID}`, { status: 500, headers: { @@ -95,6 +104,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } + if (local(c.req.method, url.pathname)) { + // No instance provided because we are serving cached data; there + // is no instance to work with + return next() + } + const adaptor = await getAdaptor(workspace.projectID, workspace.type) const target = await adaptor.target(workspace) @@ -112,24 +127,27 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - if (local(c.req.method, url.pathname)) { - // No instance provided because we are serving cached data; there - // is no instance to work with - return next() - } + const proxyURL = new URL(target.url) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` + proxyURL.search = url.search + proxyURL.hash = url.hash + proxyURL.searchParams.delete("workspace") + + log.info("workspace proxy forwarding", { + workspaceID, + request: url.toString(), + target: String(target.url), + proxy: proxyURL.toString(), + }) if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(upgrade, target, c.req.raw, c.env) + return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) } const headers = new Headers(c.req.raw.headers) headers.delete("x-opencode-workspace") - return ServerProxy.http( - target, - new Request(c.req.raw, { - headers, - }), - ) + const req = new Request(c.req.raw, { headers }) + return ServerProxy.http(proxyURL, target.headers, req) } } diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/instance/sync.ts new file mode 100644 index 0000000000..c22969130a --- /dev/null +++ b/packages/opencode/src/server/instance/sync.ts @@ -0,0 +1,118 @@ +import z from "zod" +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { SyncEvent } from "@/sync" +import { Database, asc, and, not, or, lte, eq } from "@/storage/db" +import { EventTable } from "@/sync/event.sql" +import { lazy } from "@/util/lazy" +import { Log } from "@/util/log" +import { errors } from "../error" + +const ReplayEvent = z.object({ + id: z.string(), + aggregateID: z.string(), + seq: z.number().int().min(0), + type: z.string(), + data: z.record(z.string(), z.unknown()), +}) + +const log = Log.create({ service: "server.sync" }) + +export const SyncRoutes = lazy(() => + new Hono() + .post( + "/replay", + describeRoute({ + summary: "Replay sync events", + description: "Validate and replay a complete sync event history.", + operationId: "sync.replay", + responses: { + 200: { + description: "Replayed sync events", + content: { + "application/json": { + schema: resolver( + z.object({ + sessionID: z.string(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + directory: z.string(), + events: z.array(ReplayEvent).min(1), + }), + ), + async (c) => { + const body = c.req.valid("json") + const events = body.events + const source = events[0].aggregateID + log.info("sync replay requested", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + directory: body.directory, + }) + SyncEvent.replayAll(events) + + log.info("sync replay complete", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + }) + + return c.json({ + sessionID: source, + }) + }, + ) + .get( + "/history", + describeRoute({ + summary: "List sync events", + description: + "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + operationId: "sync.history.list", + responses: { + 200: { + description: "Sync events", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + aggregate_id: z.string(), + seq: z.number(), + type: z.string(), + data: z.record(z.string(), z.unknown()), + }), + ), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.record(z.string(), z.number().int().min(0))), + async (c) => { + const body = c.req.valid("json") + const exclude = Object.entries(body) + const where = + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined + const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) + return c.json(rows) + }, + ), +) diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index 7cee031975..a4ff4eda8d 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -6,12 +6,10 @@ import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Log } from "@/util/log" +import { errorData } from "@/util/error" -const WorkspaceAdaptor = z.object({ - type: z.string(), - name: z.string(), - description: z.string(), -}) +const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() @@ -26,7 +24,15 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace adaptors", content: { "application/json": { - schema: resolver(z.array(WorkspaceAdaptor)), + schema: resolver( + z.array( + z.object({ + type: z.string(), + name: z.string(), + description: z.string(), + }), + ), + ), }, }, }, @@ -140,5 +146,58 @@ export const WorkspaceRoutes = lazy(() => const { id } = c.req.valid("param") return c.json(await Workspace.remove(id)) }, + ) + .post( + "/:id/session-restore", + describeRoute({ + summary: "Restore session into workspace", + description: "Replay a session's sync events into the target workspace in batches.", + operationId: "experimental.workspace.sessionRestore", + responses: { + 200: { + description: "Session replay started", + content: { + "application/json": { + schema: resolver( + z.object({ + total: z.number().int().min(0), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ id: Workspace.Info.shape.id })), + validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), + async (c) => { + const { id } = c.req.valid("param") + const body = c.req.valid("json") + log.info("session restore route requested", { + workspaceID: id, + sessionID: body.sessionID, + directory: Instance.directory, + }) + try { + const result = await Workspace.sessionRestore({ + workspaceID: id, + ...body, + }) + log.info("session restore route complete", { + workspaceID: id, + sessionID: body.sessionID, + total: result.total, + }) + return c.json(result) + } catch (err) { + log.error("session restore route failed", { + workspaceID: id, + sessionID: body.sessionID, + error: errorData(err), + }) + throw err + } + }, ), ) diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index a51ba602b5..d0539eb247 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -86,7 +86,7 @@ const zipped = compress() export const CompressionMiddleware: MiddlewareHandler = (c, next) => { const path = c.req.path const method = c.req.method - if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next() + if (path === "/event" || path === "/global/event") return next() if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() return zipped(c, next) } diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index c90a657dc2..0c0deba20c 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,6 +1,6 @@ -import type { Target } from "@/control-plane/types" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" +import { Log } from "@/util/log" const hop = new Set([ "connection", @@ -20,6 +20,7 @@ type Msg = string | ArrayBuffer | Uint8Array function headers(req: Request, extra?: HeadersInit) { const out = new Headers(req.headers) for (const key of hop) out.delete(key) + out.delete("accept-encoding") out.delete("x-opencode-directory") out.delete("x-opencode-workspace") if (!extra) return out @@ -98,31 +99,63 @@ const app = (upgrade: UpgradeWebSocket) => ) export namespace ServerProxy { - export function http(target: Extract, req: Request) { + const log = Log.Default.clone().tag("service", "server-proxy") + + export function http(url: string | URL, extra: HeadersInit | undefined, req: Request) { + console.log("proxy http request", { + method: req.method, + request: req.url, + url: String(url), + }) return fetch( - new Request(target.url, { + new Request(url, { method: req.method, - headers: headers(req, target.headers), + headers: headers(req, extra), body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, redirect: "manual", signal: req.signal, }), - ) + ).then((res) => { + const next = new Headers(res.headers) + next.delete("content-encoding") + next.delete("content-length") + + console.log("proxy http response", { + method: req.method, + request: req.url, + url: String(url), + status: res.status, + statusText: res.statusText, + }) + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: next, + }) + }) } export function websocket( upgrade: UpgradeWebSocket, - target: Extract, + target: string | URL, + extra: HeadersInit | undefined, req: Request, env: unknown, ) { - const url = new URL(req.url) - url.pathname = "/__workspace_ws" - url.search = "" + const proxy = new URL(req.url) + proxy.pathname = "/__workspace_ws" + proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", socket(target.url)) + next.set("x-opencode-proxy-url", socket(target)) + for (const [key, value] of new Headers(extra).entries()) { + next.set(key, value) + } + log.info("proxy websocket", { + request: req.url, + target: String(target), + }) return app(upgrade).fetch( - new Request(url, { + new Request(proxy, { method: req.method, headers: next, signal: req.signal, diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index d7cb7f774f..ce598dae67 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -199,6 +199,25 @@ export namespace SyncEvent { process(def, event, { publish: !!options?.publish }) } + export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { + const source = events[0]?.aggregateID + if (!source) return + if (events.some((item) => item.aggregateID !== source)) { + throw new Error("Replay events must belong to the same session") + } + const start = events[0].seq + for (const [i, item] of events.entries()) { + const seq = start + i + if (item.seq !== seq) { + throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) + } + } + for (const item of events) { + replay(item, options) + } + return source + } + export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { const agg = (data as Record)[def.aggregate] // This should never happen: we've enforced it via typescript in diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b5fc976bba..d7bf43f506 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -35,6 +35,8 @@ import type { ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceSessionRestoreErrors, + ExperimentalWorkspaceSessionRestoreResponses, ExperimentalWorkspaceStatusResponses, FileListResponses, FilePartInput, @@ -157,6 +159,10 @@ import type { SessionUpdateErrors, SessionUpdateResponses, SubtaskPartInput, + SyncHistoryListErrors, + SyncHistoryListResponses, + SyncReplayErrors, + SyncReplayResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -1243,6 +1249,49 @@ export class Workspace extends HeyApiClient { }) } + /** + * Restore session into workspace + * + * Replay a session's sync events into the target workspace in batches. + */ + public sessionRestore( + parameters: { + id: string + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceSessionRestoreErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}/session-restore", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _adaptor?: Adaptor get adaptor(): Adaptor { return (this._adaptor ??= new Adaptor({ client: this.client })) @@ -2961,6 +3010,109 @@ export class Provider extends HeyApiClient { } } +export class History extends HeyApiClient { + /** + * List sync events + * + * List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history. + */ + public list( + parameters?: { + directory?: string + workspace?: string + body?: { + [key: string]: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "body", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/sync/history", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Sync extends HeyApiClient { + /** + * Replay sync events + * + * Validate and replay a complete sync event history. + */ + public replay( + parameters?: { + query_directory?: string + workspace?: string + body_directory?: string + events?: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { + in: "query", + key: "query_directory", + map: "directory", + }, + { in: "query", key: "workspace" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, + { in: "body", key: "events" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/replay", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _history?: History + get history(): History { + return (this._history ??= new History({ client: this.client })) + } +} + export class Find extends HeyApiClient { /** * Find text @@ -4217,6 +4369,11 @@ export class OpencodeClient extends HeyApiClient { return (this._provider ??= new Provider({ client: this.client })) } + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8f4c16c5bd..24c1d53bf7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -520,6 +520,16 @@ export type EventWorkspaceFailed = { } } +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + export type EventWorkspaceStatus = { type: "workspace.status" properties: { @@ -1137,6 +1147,7 @@ export type GlobalEvent = { | EventPtyDeleted | EventWorkspaceReady | EventWorkspaceFailed + | EventWorkspaceRestore | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved @@ -2049,6 +2060,7 @@ export type Event = | EventPtyDeleted | EventWorkspaceReady | EventWorkspaceFailed + | EventWorkspaceRestore | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved @@ -3006,6 +3018,42 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + export type WorktreeRemoveData = { body?: WorktreeRemoveInput path?: never @@ -4456,6 +4504,85 @@ export type ProviderOauthCallbackResponses = { export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type SyncReplayData = { + body?: { + directory: string + events: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/replay" +} + +export type SyncReplayErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncReplayError = SyncReplayErrors[keyof SyncReplayErrors] + +export type SyncReplayResponses = { + /** + * Replayed sync events + */ + 200: { + sessionID: string + } +} + +export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] + +export type SyncHistoryListData = { + body?: { + [key: string]: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/history" +} + +export type SyncHistoryListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncHistoryListError = SyncHistoryListErrors[keyof SyncHistoryListErrors] + +export type SyncHistoryListResponses = { + /** + * Sync events + */ + 200: Array<{ + id: string + aggregate_id: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> +} + +export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] + export type FindTextData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6000e66042..ee3538d55f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1805,6 +1805,90 @@ ] } }, + "/experimental/workspace/{id}/session-restore": { + "post": { + "operationId": "experimental.workspace.sessionRestore", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "summary": "Restore session into workspace", + "description": "Replay a session's sync events into the target workspace in batches.", + "responses": { + "200": { + "description": "Session replay started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["total"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + } + ] + } + }, "/experimental/worktree": { "post": { "operationId": "worktree.create", @@ -5143,6 +5227,202 @@ ] } }, + "/sync/replay": { + "post": { + "operationId": "sync.replay", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Replay sync events", + "description": "Validate and replay a complete sync event history.", + "responses": { + "200": { + "description": "Replayed sync events", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "aggregateID", "seq", "type", "data"] + } + } + }, + "required": ["directory", "events"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})" + } + ] + } + }, + "/sync/history": { + "get": { + "operationId": "sync.history.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List sync events", + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "responses": { + "200": { + "description": "Sync events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregate_id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "aggregate_id", "seq", "type", "data"] + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.history.list({\n ...\n})" + } + ] + } + }, "/find": { "get": { "operationId": "find.text", @@ -8514,6 +8794,40 @@ }, "required": ["type", "properties"] }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, "Event.workspace.status": { "type": "object", "properties": { @@ -10523,6 +10837,9 @@ { "$ref": "#/components/schemas/Event.workspace.failed" }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, { "$ref": "#/components/schemas/Event.workspace.status" }, @@ -12780,6 +13097,9 @@ { "$ref": "#/components/schemas/Event.workspace.failed" }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, { "$ref": "#/components/schemas/Event.workspace.status" }, From be9432a893dd1662c10ff41c7ab552bcba8f3e1b Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 15 Apr 2026 10:26:20 -0400 Subject: [PATCH 130/154] shared package (#22626) --- bun.lock | 80 +++++++++---------- package.json | 2 + packages/app/package.json | 2 +- .../src/components/dialog-edit-project.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 2 +- .../components/dialog-select-directory.tsx | 2 +- .../app/src/components/dialog-select-file.tsx | 4 +- .../prompt-input/build-request-parts.ts | 2 +- .../components/prompt-input/context-items.tsx | 2 +- .../components/prompt-input/slash-popover.tsx | 2 +- .../components/prompt-input/submit.test.ts | 2 +- .../app/src/components/prompt-input/submit.ts | 4 +- .../session/session-context-tab.tsx | 4 +- .../src/components/session/session-header.tsx | 2 +- .../components/session/session-new-view.tsx | 2 +- .../session/session-sortable-tab.tsx | 2 +- packages/app/src/context/file.tsx | 2 +- packages/app/src/context/global-sync.tsx | 2 +- .../app/src/context/global-sync/bootstrap.ts | 4 +- .../src/context/global-sync/event-reducer.ts | 2 +- packages/app/src/context/local.tsx | 2 +- packages/app/src/context/notification.tsx | 4 +- .../context/permission-auto-respond.test.ts | 2 +- .../src/context/permission-auto-respond.ts | 2 +- packages/app/src/context/prompt.tsx | 2 +- packages/app/src/context/sync.tsx | 4 +- packages/app/src/pages/directory-layout.tsx | 2 +- packages/app/src/pages/home.tsx | 2 +- packages/app/src/pages/layout.tsx | 8 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 2 +- .../app/src/pages/layout/sidebar-project.tsx | 2 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/app/src/pages/session.tsx | 2 +- packages/app/src/pages/session/file-tabs.tsx | 2 +- .../src/pages/session/message-timeline.tsx | 4 +- .../pages/session/use-session-commands.tsx | 2 +- packages/app/src/utils/base64.ts | 2 +- packages/app/src/utils/persist.ts | 2 +- packages/enterprise/package.json | 2 +- packages/enterprise/src/core/share.ts | 4 +- packages/enterprise/src/core/storage.ts | 2 +- .../enterprise/src/routes/share/[shareID].tsx | 6 +- packages/enterprise/test/core/share.test.ts | 2 +- packages/opencode/package.json | 2 +- packages/opencode/src/auth/index.ts | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- .../src/cli/cmd/tui/context/theme.tsx | 2 +- packages/opencode/src/cli/ui.ts | 2 +- packages/opencode/src/config/config.ts | 8 +- packages/opencode/src/config/markdown.ts | 2 +- packages/opencode/src/config/paths.ts | 2 +- packages/opencode/src/config/tui.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/file/ignore.ts | 2 +- packages/opencode/src/file/index.ts | 2 +- packages/opencode/src/file/time.ts | 2 +- packages/opencode/src/format/formatter.ts | 2 +- packages/opencode/src/ide/index.ts | 2 +- packages/opencode/src/index.ts | 2 +- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/lsp/server.ts | 4 +- packages/opencode/src/mcp/auth.ts | 2 +- packages/opencode/src/mcp/index.ts | 4 +- packages/opencode/src/npm/index.ts | 2 +- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/plugin/shared.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/provider.ts | 4 +- packages/opencode/src/pty/index.ts | 2 +- .../opencode/src/server/instance/session.ts | 2 +- packages/opencode/src/server/middleware.ts | 2 +- packages/opencode/src/session/index.ts | 2 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/message.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/retry.ts | 2 +- packages/opencode/src/skill/discovery.ts | 2 +- packages/opencode/src/skill/index.ts | 6 +- packages/opencode/src/snapshot/index.ts | 2 +- packages/opencode/src/storage/db.ts | 2 +- .../opencode/src/storage/json-migration.ts | 2 +- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/src/tool/truncate.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/src/util/filesystem.ts | 2 +- packages/opencode/src/util/log.ts | 2 +- packages/opencode/src/worktree/index.ts | 6 +- packages/opencode/test/config/config.test.ts | 2 +- .../test/filesystem/filesystem.test.ts | 2 +- .../opencode/test/project/project.test.ts | 2 +- .../test/session/prompt-effect.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 2 +- packages/opencode/test/session/retry.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- .../opencode/test/storage/storage.test.ts | 2 +- .../opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 2 +- packages/opencode/test/util/glob.test.ts | 2 +- packages/opencode/test/util/module.test.ts | 2 +- packages/shared/package.json | 31 +++++++ .../index.ts => shared/src/filesystem.ts} | 2 +- .../{util/src => shared/src/util}/array.ts | 0 .../{util/src => shared/src/util}/binary.ts | 0 .../{util/src => shared/src/util}/encode.ts | 0 .../{util/src => shared/src/util}/error.ts | 0 packages/{util/src => shared/src/util}/fn.ts | 0 .../{opencode => shared}/src/util/glob.ts | 0 .../src => shared/src/util}/identifier.ts | 0 .../{util/src => shared/src/util}/iife.ts | 0 .../{util/src => shared/src/util}/lazy.ts | 0 .../{util/src => shared/src/util}/module.ts | 0 .../{util/src => shared/src/util}/path.ts | 0 .../{util/src => shared/src/util}/retry.ts | 0 .../{util/src => shared/src/util}/slug.ts | 0 packages/shared/tsconfig.json | 23 ++++++ packages/ui/package.json | 2 +- packages/ui/src/components/file.tsx | 2 +- packages/ui/src/components/line-comment.tsx | 2 +- packages/ui/src/components/markdown.tsx | 2 +- packages/ui/src/components/message-part.tsx | 4 +- packages/ui/src/components/session-review.tsx | 4 +- packages/ui/src/components/session-turn.tsx | 4 +- packages/util/package.json | 20 ----- packages/util/sst-env.d.ts | 10 --- packages/util/tsconfig.json | 14 ---- 144 files changed, 246 insertions(+), 242 deletions(-) create mode 100644 packages/shared/package.json rename packages/{opencode/src/filesystem/index.ts => shared/src/filesystem.ts} (99%) rename packages/{util/src => shared/src/util}/array.ts (100%) rename packages/{util/src => shared/src/util}/binary.ts (100%) rename packages/{util/src => shared/src/util}/encode.ts (100%) rename packages/{util/src => shared/src/util}/error.ts (100%) rename packages/{util/src => shared/src/util}/fn.ts (100%) rename packages/{opencode => shared}/src/util/glob.ts (100%) rename packages/{util/src => shared/src/util}/identifier.ts (100%) rename packages/{util/src => shared/src/util}/iife.ts (100%) rename packages/{util/src => shared/src/util}/lazy.ts (100%) rename packages/{util/src => shared/src/util}/module.ts (100%) rename packages/{util/src => shared/src/util}/path.ts (100%) rename packages/{util/src => shared/src/util}/retry.ts (100%) rename packages/{util/src => shared/src/util}/slug.ts (100%) create mode 100644 packages/shared/tsconfig.json delete mode 100644 packages/util/package.json delete mode 100644 packages/util/sst-env.d.ts delete mode 100644 packages/util/tsconfig.json diff --git a/bun.lock b/bun.lock index e7085b31da..01966b826a 100644 --- a/bun.lock +++ b/bun.lock @@ -31,8 +31,8 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/util": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -268,8 +268,8 @@ "name": "@opencode-ai/enterprise", "version": "1.4.6", "dependencies": { + "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/util": "workspace:*", "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", @@ -358,7 +358,6 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", - "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "2.5.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", @@ -424,6 +423,7 @@ "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -513,6 +513,25 @@ "typescript": "catalog:", }, }, + "packages/shared": { + "name": "@opencode-ai/shared", + "version": "1.4.6", + "bin": { + "opencode": "./bin/opencode", + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@npmcli/arborist": "catalog:", + "effect": "catalog:", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "semver": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "@types/semver": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.4.6", @@ -554,7 +573,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", @@ -597,17 +616,6 @@ "vite-plugin-solid": "catalog:", }, }, - "packages/util": { - "name": "@opencode-ai/util", - "version": "1.4.6", - "dependencies": { - "zod": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/web": { "name": "@opencode-ai/web", "version": "1.4.6", @@ -666,6 +674,7 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", + "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.1.0-beta.18", @@ -698,6 +707,7 @@ "marked-shiki": "1.2.1", "remeda": "2.26.0", "remend": "1.3.0", + "semver": "7.7.4", "shiki": "3.20.0", "solid-js": "1.9.10", "solid-list": "0.3.0", @@ -1554,14 +1564,14 @@ "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], - "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], - "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], @@ -2780,7 +2790,7 @@ "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], @@ -3880,7 +3890,7 @@ "miniflare": ["miniflare@4.20251118.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251118.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-uLSAE/DvOm392fiaig4LOaatxLjM7xzIniFRG5Y3yF9IduOYLLK/pkCPQNCgKQH3ou0YJRHnTN+09LPfqYNTQQ=="], - "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -3938,7 +3948,7 @@ "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], - "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], @@ -5440,12 +5450,6 @@ "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@npmcli/arborist/common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], - - "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@npmcli/map-workspaces/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="], @@ -5614,8 +5618,6 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@tufjs/models/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], @@ -5628,6 +5630,8 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], @@ -5650,8 +5654,6 @@ "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], - "app-builder-lib/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -5660,6 +5662,8 @@ "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], @@ -5784,8 +5788,6 @@ "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -5802,8 +5804,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -5818,8 +5818,6 @@ "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], @@ -5858,6 +5856,8 @@ "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "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=="], "opencode-poe-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=="], @@ -6668,8 +6668,6 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -6806,8 +6804,6 @@ "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], - "@electron/rebuild/node-gyp/make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@electron/rebuild/node-gyp/make-fetch-happen/ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], "@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], @@ -6872,8 +6868,6 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7072,8 +7066,6 @@ "js-beautify/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "opencontrol/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/package.json b/package.json index 282506206b..abe1b5d362 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "catalog": { "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", + "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.11", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", @@ -59,6 +60,7 @@ "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", + "semver": "7.7.4", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", diff --git a/packages/app/package.json b/packages/app/package.json index 9aadb774b7..483c71dc50 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,7 +42,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index eb962f47eb..ea5d70065a 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -9,7 +9,7 @@ import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 9e1b896fa8..710618c301 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -9,7 +9,7 @@ import { List } from "@opencode-ai/ui/list" import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLanguage } from "@/context/language" interface ForkableMessage { diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 91e23f8ffa..903cb1915d 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -3,7 +3,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index e21be77fb9..a0347a0399 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -4,8 +4,8 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/util/encode" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import { useNavigate } from "@solidjs/router" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index a1076e60ca..c268af35ee 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b138fe3ef6..9f20f1c04b 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/shared/util/path" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c797..0c8c959234 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,7 +1,7 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" export type AtOption = | { type: "agent"; name: string; display: string } diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 03bece2e31..cf99497232 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -74,7 +74,7 @@ beforeAll(async () => { showToast: () => 0, })) - mock.module("@opencode-ai/util/encode", () => ({ + mock.module("@opencode-ai/shared/util/encode", () => ({ base64Encode: (value: string) => value, })) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index d147d7b502..27e8980431 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,7 +1,7 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" -import { Binary } from "@opencode-ai/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 4e7dc8e783..abf4c93346 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,8 +1,8 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" +import { checksum } from "@opencode-ai/shared/util/encode" +import { findLast } from "@opencode-ai/shared/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b323405..e65b575ac5 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,7 +7,7 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index e4ef363936..d2cac28fc4 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -5,7 +5,7 @@ import { useSDK } from "@/context/sdk" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index dfda91c160..fb2275c445 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -5,7 +5,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index f8fec7142d..8998731a6c 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,7 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b..fe5f2f1301 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -8,7 +8,7 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" 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" diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c4deb431a7..ad987efa6e 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -11,8 +11,8 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { retry } from "@opencode-ai/util/retry" +import { getFilename } from "@opencode-ai/shared/util/path" +import { retry } from "@opencode-ai/shared/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 500013c1da..11a0cf83fd 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,4 @@ -import { Binary } from "@opencode-ai/util/binary" +import { Binary } from "@opencode-ai/shared/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 28ce2770de..0b0972ee67 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 281a1ef33d..251b67b06c 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -7,8 +7,8 @@ import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { Binary } from "@opencode-ai/util/binary" -import { base64Encode } from "@opencode-ai/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 7556113005..2f8ca6265e 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedff..2ebca34347 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -1,4 +1,4 @@ -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" export function acceptKey(sessionID: string, directory?: string) { if (!directory) return sessionID diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 831fdbca83..9b666e5e75 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index fb02a2d2d0..29b7fe68c5 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,7 +1,7 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" import { clearSessionPrefetch, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 427b4823b5..f604dd6c5c 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4c795b9683..46cacdf627 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -3,7 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Icon } from "@opencode-ai/ui/icon" import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f402f4bc04..62d5cba615 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -17,7 +17,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -48,8 +48,8 @@ import { } from "@/context/global-sync/session-prefetch" 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 { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 48158debba..26b66d1668 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" type SessionStore = { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index e56accfc83..b0f45859a4 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { A, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aafb..076e1ef88b 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,6 +1,6 @@ import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 878b6e5fa2..9e00691471 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -3,8 +3,8 @@ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "so import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { createMediaQuery } from "@solid-primitives/media" -import { base64Encode } from "@opencode-ai/util/encode" -import { getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { getFilename } from "@opencode-ai/shared/util/path" import { Button } from "@opencode-ai/ui/button" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index eb6a494119..e328e3f0cc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -27,7 +27,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index cb76175236..a64dff64e2 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" -import { sampledChecksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/shared/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8380163957..978f188b6b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -15,8 +15,8 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" -import { Binary } from "@opencode-ai/util/binary" -import { getFilename } from "@opencode-ai/util/path" +import { Binary } from "@opencode-ai/shared/util/binary" +import { getFilename } from "@opencode-ai/shared/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 2397953737..b5d2544636 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,7 +12,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" -import { findLast } from "@opencode-ai/util/array" +import { findLast } from "@opencode-ai/shared/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts index c1f9d88c6e..f60dff2b6d 100644 --- a/packages/app/src/utils/base64.ts +++ b/packages/app/src/utils/base64.ts @@ -1,4 +1,4 @@ -import { base64Decode } from "@opencode-ai/util/encode" +import { base64Decode } from "@opencode-ai/shared/util/encode" export function decode64(value: string | undefined) { if (value === undefined) return diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 3dcbeb7d36..dce0e94c3b 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,6 +1,6 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 0d165e95ff..3c4a835f35 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -13,7 +13,7 @@ "shell-prod": "sst shell --target Teams --stage production" }, "dependencies": { - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", "aws4fetch": "^1.0.20", "@pierre/diffs": "catalog:", diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index 18fcd7a071..1a343272f7 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,6 +1,6 @@ import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { fn } from "@opencode-ai/util/fn" -import { iife } from "@opencode-ai/util/iife" +import { fn } from "@opencode-ai/shared/util/fn" +import { iife } from "@opencode-ai/shared/util/iife" import z from "zod" import { Storage } from "./storage" diff --git a/packages/enterprise/src/core/storage.ts b/packages/enterprise/src/core/storage.ts index b8030b4f90..a6222e4154 100644 --- a/packages/enterprise/src/core/storage.ts +++ b/packages/enterprise/src/core/storage.ts @@ -1,5 +1,5 @@ import { AwsClient } from "aws4fetch" -import { lazy } from "@opencode-ai/util/lazy" +import { lazy } from "@opencode-ai/shared/util/lazy" export namespace Storage { export interface Adapter { diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index edeeaf1ad5..f3be14e393 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -10,9 +10,9 @@ import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { iife } from "@opencode-ai/util/iife" -import { Binary } from "@opencode-ai/util/binary" -import { NamedError } from "@opencode-ai/util/error" +import { iife } from "@opencode-ai/shared/util/iife" +import { Binary } from "@opencode-ai/shared/util/binary" +import { NamedError } from "@opencode-ai/shared/util/error" import { DateTime } from "luxon" import { createStore } from "solid-js/store" import z from "zod" diff --git a/packages/enterprise/test/core/share.test.ts b/packages/enterprise/test/core/share.test.ts index d49d4b7639..34f3b17a3f 100644 --- a/packages/enterprise/test/core/share.test.ts +++ b/packages/enterprise/test/core/share.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, afterAll } from "bun:test" import { Share } from "../../src/core/share" import { Storage } from "../../src/core/storage" -import { Identifier } from "@opencode-ai/util/identifier" +import { Identifier } from "@opencode-ai/shared/util/identifier" describe.concurrent("core.share", () => { test("should create a share", async () => { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0f0c26ed96..9ddf1fa9f6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -44,6 +44,7 @@ "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -115,7 +116,6 @@ "@opencode-ai/server": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/util": "workspace:*", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b1502da78c..b287ce551e 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { zod } from "@/util/effect-zod" import { Global } from "../global" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 772b5d9a0c..cab162f8f0 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 { useProject } from "@tui/context/project" import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" -import { Binary } from "@opencode-ai/util/binary" +import { Binary } from "@opencode-ai/shared/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index de81529961..0c0658e743 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -2,7 +2,7 @@ import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentu import path from "path" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" -import { Glob } from "../../../../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" import aura from "./theme/aura.json" with { type: "json" } import ayu from "./theme/ayu.json" with { type: "json" } import catppuccin from "./theme/catppuccin.json" with { type: "json" } diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index b24a2a2f44..d735a55417 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,6 @@ import z from "zod" import { EOL } from "os" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { logo as glyphs } from "./logo" export namespace UI { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f9ca883414..f8205bac26 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -7,7 +7,7 @@ import z from "zod" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { Env } from "../env" @@ -26,17 +26,17 @@ import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" -import { Glob } from "../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" import { Account } from "@/account" import { isRecord } from "@/util/record" import { ConfigPaths } from "./paths" import type { ConsoleState } from "./console-state" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { Npm } from "@/npm" +import { Npm } from "../npm" import { InstanceRef } from "@/effect/instance-ref" export namespace Config { diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 3c9709b5b3..2f1483dca3 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,4 +1,4 @@ -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 82ccf3945f..884a774499 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -2,7 +2,7 @@ import path from "path" import os from "os" import z from "zod" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Global } from "@/global" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 9347a8cc4c..87c39e700a 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -13,7 +13,7 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" export namespace TuiConfig { const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 78f3d770eb..b9ac0a6b43 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -11,7 +11,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" -import { Slug } from "@opencode-ai/util/slug" +import { Slug } from "@opencode-ai/shared/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index b13396615f..5948bd25e6 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -2,7 +2,7 @@ import { Layer, ManagedRuntime } from "effect" import { attach, memoMap } from "./run-service" import { Observability } from "./observability" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account" diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index b9731040c7..a102e7d170 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -1,5 +1,5 @@ import { sep } from "node:path" -import { Glob } from "../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" export namespace FileIgnore { const FOLDERS = new Set([ diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6730957f23..113dc59096 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" import { Effect, Layer, Context } from "effect" import * as Stream from "effect/Stream" diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index e5055b6718..5537526730 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,6 +1,6 @@ import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" import { Filesystem } from "@/util/filesystem" diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index ada661ebab..6c17310ff8 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,4 +1,4 @@ -import { Npm } from "@/npm" +import { Npm } from "../npm" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index ce4128b906..46efea2cce 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import z from "zod" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Log } from "../util/log" import { Process } from "@/util/process" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 753becc267..641411461d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -11,7 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" import { Filesystem } from "./util/filesystem" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index de0c438626..fe5a9ab182 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -9,7 +9,7 @@ import { Process } from "../util/process" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type { LSPServer } from "./server" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index abfb31ead0..9ffef7a425 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -11,9 +11,9 @@ import { Flag } from "../flag/flag" import { Archive } from "../util/archive" import { Process } from "../util/process" import { which } from "../util/which" -import { Module } from "@opencode-ai/util/module" +import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" -import { Npm } from "@/npm" +import { Npm } from "../npm" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 6eefc107d9..85f9e1d8c9 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -2,7 +2,7 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Effect, Layer, Context } from "effect" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" export namespace McpAuth { export const Tokens = z.object({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3de427d7e4..3b66909340 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -11,12 +11,12 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Config } from "../config/config" import { Log } from "../util/log" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod/v4" import { Instance } from "../project/instance" import { Installation } from "../installation" import { withTimeout } from "@/util/timeout" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 3568ff20e2..5b708431c6 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,6 +1,6 @@ import semver from "semver" import z from "zod" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Global } from "../global" import { Log } from "../util/log" import path from "path" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0dc53d997d..c716ffdf8d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -12,7 +12,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 6cda49786b..54cc32af5b 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -2,7 +2,7 @@ import path from "path" import { fileURLToPath, pathToFileURL } from "url" import npa from "npm-package-arg" import semver from "semver" -import { Npm } from "@/npm" +import { Npm } from "../npm" import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f9d634a1cd..d20bf42494 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -11,7 +11,7 @@ import { ProjectID } from "./schema" import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" export namespace Project { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index aede980d6d..e4093fd456 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -4,7 +4,7 @@ import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import { Log } from "@/util/log" diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e410b86365..c66ccffc12 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,5 +1,5 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 84f074d42d..d34721f1d8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -8,7 +8,7 @@ import { Log } from "../util/log" import { Npm } from "../npm" import { Hash } from "../util/hash" import { Plugin } from "../plugin" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { type LanguageModelV3 } from "@ai-sdk/provider" import { ModelsDev } from "./models" import { Auth } from "../auth" @@ -21,7 +21,7 @@ import path from "path" import { Effect, Layer, Context } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" // Direct imports for bundled providers diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1891721851..9c79eb2d4c 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -5,7 +5,7 @@ import { Instance } from "@/project/instance" import type { Proc } from "#pty" import z from "zod" import { Log } from "../util/log" -import { lazy } from "@opencode-ai/util/lazy" +import { lazy } from "@opencode-ai/shared/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index e443a6ddd2..a011c32f9b 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -25,7 +25,7 @@ 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" +import { NamedError } from "@opencode-ai/shared/util/error" const log = Log.create({ service: "server" }) diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index d0539eb247..6e91651866 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -1,5 +1,5 @@ import { Provider } from "../provider/provider" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { NotFoundError } from "../storage/db" import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index aa3404ad75..d8ab812349 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,4 @@ -import { Slug } from "@opencode-ai/util/slug" +import { Slug } from "@opencode-ai/shared/util/slug" import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 04f2610dfe..b4794ba5b1 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -5,7 +5,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@/flag/flag" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4c18d1f7e0..8c82d4d73f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" import z from "zod" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index ee5eac08b6..396034825a 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,7 +1,7 @@ import z from "zod" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" export namespace Message { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3efcc03657..f8c794505e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,14 +32,14 @@ import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 5ec9a585b0..39eb8cfb74 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -1,4 +1,4 @@ -import type { NamedError } from "@opencode-ai/util/error" +import type { NamedError } from "@opencode-ai/shared/util/error" import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0bc3ee6290..0323f250f6 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node" import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Global } from "../global" import { Log } from "../util/log" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 6c4f290a08..79b426c69c 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -3,17 +3,17 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context } from "effect" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" import { Log } from "../util/log" import { Discovery } from "./discovery" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 06c91442ac..2b21f7e895 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -5,7 +5,7 @@ import path from "path" import z from "zod" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Hash } from "@/util/hash" import { Config } from "../config/config" import { Global } from "../global" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index a7dbf93800..68a41e471f 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -6,7 +6,7 @@ import { LocalContext } from "../util/local-context" import { lazy } from "../util/lazy" import { Global } from "../global" import { Log } from "../util/log" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 400e3dc9ef..89d27b9a7b 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -8,7 +8,7 @@ import { SessionShareTable } from "../share/share.sql" import path from "path" import { existsSync } from "fs" import { Filesystem } from "../util/filesystem" -import { Glob } from "../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" export namespace JsonMigration { const log = Log.create({ service: "json-migration" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index a123cd664f..359c750ced 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,9 +1,9 @@ import { Log } from "../util/log" import path from "path" import { Global } from "../global" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { Git } from "@/git" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index fd38a9b224..b9877d8fec 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -10,7 +10,7 @@ import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectoryEffect } from "./external-directory" import { trimDiff } from "./edit" import { LSP } from "../lsp" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ad6739b9a9..3bb936944c 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -8,7 +8,7 @@ import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a835714c69..bc8478e39f 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -19,7 +19,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 9df3e0aaf5..352cc07390 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -4,7 +4,7 @@ import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import type { Tool } from "./tool" import { Instance } from "../project/instance" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Kind = "file" | "directory" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index c1577bc7d6..778a74ddcf 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -3,7 +3,7 @@ import z from "zod" import { Effect, Option } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 0d717ba372..9a2bab5b2d 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -2,7 +2,7 @@ import path from "path" import z from "zod" import { Effect, Option } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index c5a5d6f819..36cab3c1c3 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -7,7 +7,7 @@ import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" import { assertExternalDirectoryEffect } from "./external-directory" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" const operations = [ "goToDefinition", diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 501a8c97ed..701bfc4b9d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -5,7 +5,7 @@ import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" import { Tool } from "./tool" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2e7da5b506..6900feecc3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,7 +26,7 @@ import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncate" import { ApplyPatchTool } from "./apply_patch" -import { Glob } from "../util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" @@ -41,7 +41,7 @@ import { Todo } from "../session/todo" import { LSP } from "../lsp" import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" import { Skill } from "../skill" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index e6bab1a16b..a7bd8a4b16 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect" import path from "path" import type { Agent } from "../agent/agent" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { evaluate } from "@/permission/evaluate" import { Identifier } from "../id/id" import { Log } from "../util/log" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 7a9d82cf8b..337c2708c9 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" import { FileTime } from "../file/time" -import { AppFileSystem } from "../filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 5f50231b03..b4aef05456 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -5,7 +5,7 @@ import { realpathSync } from "fs" import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" -import { Glob } from "./glob" +import { Glob } from "@opencode-ai/shared/util/glob" export namespace Filesystem { // Fast sync version for metadata checks diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index f94e9866f7..7812632768 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -3,7 +3,7 @@ import fs from "fs/promises" import { createWriteStream } from "fs" import { Global } from "../global" import z from "zod" -import { Glob } from "./glob" +import { Glob } from "@opencode-ai/shared/util/glob" export namespace Log { export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 18240524a1..14a3a0dc9b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,5 +1,5 @@ import z from "zod" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" @@ -8,7 +8,7 @@ import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import { Log } from "../util/log" -import { Slug } from "@opencode-ai/util/slug" +import { Slug } from "@opencode-ai/shared/util/slug" import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -16,7 +16,7 @@ import { Git } from "@/git" import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e759985feb..ed7e689da4 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -5,7 +5,7 @@ import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Env } from "../../src/env" import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir, tmpdirScoped } from "../fixture/fixture" diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index ca73b3336b..0bb4ba5839 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { testEffect } from "../lib/effect" import path from "path" diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index eddd79bc6f..ba253a9205 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -9,7 +9,7 @@ import { ProjectID } from "../../src/project/schema" import { Effect, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" Log.init({ print: false }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 244f778ca8..94561206e2 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -22,7 +22,7 @@ import { Todo } from "../../src/session/todo" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c1d6f1da97..1290570b81 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,6 @@ import path from "path" import { describe, expect, test } from "bun:test" -import { NamedError } from "@opencode-ai/util/error" +import { NamedError } from "@opencode-ai/shared/util/error" import { fileURLToPath } from "url" import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index a598c37fcd..314306ba62 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { NamedError } from "@opencode-ai/util/error" +import type { NamedError } from "@opencode-ai/shared/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" import { Effect, Schedule } from "effect" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 464182395a..80d74c7565 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -53,7 +53,7 @@ import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index ea8f1feb4f..60b458bb30 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Exit, Layer } from "effect" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Git } from "../../src/git" import { Global } from "../../src/global" diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 03220ea3b1..c0448c78cb 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -5,7 +5,7 @@ import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index fc7a9e73e4..3b03da57ee 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -12,7 +12,7 @@ import { Agent } from "../../src/agent/agent" import { Truncate } from "../../src/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Plugin } from "../../src/plugin" const runtime = ManagedRuntime.make( diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 9fe24b49b2..37a19a5fda 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -7,7 +7,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 092885ed18..20e761fc10 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -5,7 +5,7 @@ import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" import { provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 7cdf6a0aa1..35467aeab4 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -8,7 +8,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { testEffect } from "../lib/effect" const it = testEffect( diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 2064193d5b..f14ec33105 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -3,7 +3,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index f7daa1e971..e83ec2efdb 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "../../src/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileTime } from "../../src/file/time" import { Bus } from "../../src/bus" import { Format } from "../../src/format" diff --git a/packages/opencode/test/util/glob.test.ts b/packages/opencode/test/util/glob.test.ts index e58d92c85c..e982d5194c 100644 --- a/packages/opencode/test/util/glob.test.ts +++ b/packages/opencode/test/util/glob.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Glob } from "../../src/util/glob" +import { Glob } from "@opencode-ai/shared/util/glob" import { tmpdir } from "../fixture/fixture" describe("Glob", () => { diff --git a/packages/opencode/test/util/module.test.ts b/packages/opencode/test/util/module.test.ts index 738b4a785b..6f8539bfb7 100644 --- a/packages/opencode/test/util/module.test.ts +++ b/packages/opencode/test/util/module.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Module } from "@opencode-ai/util/module" +import { Module } from "@opencode-ai/shared/util/module" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000000..1bb1ca47ef --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.4.6", + "name": "@opencode-ai/shared", + "type": "module", + "license": "MIT", + "private": true, + "scripts": {}, + "bin": { + "opencode": "./bin/opencode" + }, + "exports": { + "./*": "./src/*.ts" + }, + "imports": {}, + "devDependencies": { + "@types/semver": "catalog:" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@npmcli/arborist": "catalog:", + "effect": "catalog:", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "semver": "catalog:", + "zod": "catalog:" + }, + "overrides": { + "drizzle-orm": "catalog:" + } +} diff --git a/packages/opencode/src/filesystem/index.ts b/packages/shared/src/filesystem.ts similarity index 99% rename from packages/opencode/src/filesystem/index.ts rename to packages/shared/src/filesystem.ts index 2c3964ec25..44346be8f9 100644 --- a/packages/opencode/src/filesystem/index.ts +++ b/packages/shared/src/filesystem.ts @@ -5,7 +5,7 @@ import * as NFS from "fs/promises" import { lookup } from "mime-types" import { Effect, FileSystem, Layer, Schema, Context } from "effect" import type { PlatformError } from "effect/PlatformError" -import { Glob } from "../util/glob" +import { Glob } from "./util/glob" export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { diff --git a/packages/util/src/array.ts b/packages/shared/src/util/array.ts similarity index 100% rename from packages/util/src/array.ts rename to packages/shared/src/util/array.ts diff --git a/packages/util/src/binary.ts b/packages/shared/src/util/binary.ts similarity index 100% rename from packages/util/src/binary.ts rename to packages/shared/src/util/binary.ts diff --git a/packages/util/src/encode.ts b/packages/shared/src/util/encode.ts similarity index 100% rename from packages/util/src/encode.ts rename to packages/shared/src/util/encode.ts diff --git a/packages/util/src/error.ts b/packages/shared/src/util/error.ts similarity index 100% rename from packages/util/src/error.ts rename to packages/shared/src/util/error.ts diff --git a/packages/util/src/fn.ts b/packages/shared/src/util/fn.ts similarity index 100% rename from packages/util/src/fn.ts rename to packages/shared/src/util/fn.ts diff --git a/packages/opencode/src/util/glob.ts b/packages/shared/src/util/glob.ts similarity index 100% rename from packages/opencode/src/util/glob.ts rename to packages/shared/src/util/glob.ts diff --git a/packages/util/src/identifier.ts b/packages/shared/src/util/identifier.ts similarity index 100% rename from packages/util/src/identifier.ts rename to packages/shared/src/util/identifier.ts diff --git a/packages/util/src/iife.ts b/packages/shared/src/util/iife.ts similarity index 100% rename from packages/util/src/iife.ts rename to packages/shared/src/util/iife.ts diff --git a/packages/util/src/lazy.ts b/packages/shared/src/util/lazy.ts similarity index 100% rename from packages/util/src/lazy.ts rename to packages/shared/src/util/lazy.ts diff --git a/packages/util/src/module.ts b/packages/shared/src/util/module.ts similarity index 100% rename from packages/util/src/module.ts rename to packages/shared/src/util/module.ts diff --git a/packages/util/src/path.ts b/packages/shared/src/util/path.ts similarity index 100% rename from packages/util/src/path.ts rename to packages/shared/src/util/path.ts diff --git a/packages/util/src/retry.ts b/packages/shared/src/util/retry.ts similarity index 100% rename from packages/util/src/retry.ts rename to packages/shared/src/util/retry.ts diff --git a/packages/util/src/slug.ts b/packages/shared/src/util/slug.ts similarity index 100% rename from packages/util/src/slug.ts rename to packages/shared/src/util/slug.ts diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000000..ff9886313a --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "noUncheckedIndexedAccess": false, + "customConditions": ["browser"], + "paths": { + "@/*": ["./src/*"], + "@tui/*": ["./src/cli/cmd/tui/*"] + }, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index e406ecf165..21974e3ec7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,7 +44,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index b78f0bae44..51c2892737 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -1,4 +1,4 @@ -import { sampledChecksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/shared/util/encode" import { DEFAULT_VIRTUAL_FILE_METRICS, type DiffLineAnnotation, diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index 26e763bb3e..e20da5a8d3 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js" import { Button } from "./button" import { FileIcon } from "./file-icon" diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index ceab10df98..f3037da8bc 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -2,7 +2,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" import { stream } from "./markdown-stream" diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 02bd80ac9c..48444cd017 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -46,8 +46,8 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" -import { checksum } from "@opencode-ai/util/encode" +import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { checksum } from "@opencode-ai/shared/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { Spinner } from "./spinner" diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 3223f5d08d..bb19d099e0 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -11,8 +11,8 @@ import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" -import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { checksum } from "@opencode-ai/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { checksum } from "@opencode-ai/shared/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e891a6febe..6d43a575a7 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -8,8 +8,8 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" -import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { Binary } from "@opencode-ai/shared/util/binary" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" diff --git a/packages/util/package.json b/packages/util/package.json deleted file mode 100644 index 35aaa9b7c5..0000000000 --- a/packages/util/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@opencode-ai/util", - "version": "1.4.6", - "private": true, - "type": "module", - "license": "MIT", - "exports": { - "./*": "./src/*.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "zod": "catalog:" - }, - "devDependencies": { - "typescript": "catalog:", - "@types/bun": "catalog:" - } -} diff --git a/packages/util/sst-env.d.ts b/packages/util/sst-env.d.ts deleted file mode 100644 index 64441936d7..0000000000 --- a/packages/util/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/packages/util/tsconfig.json b/packages/util/tsconfig.json deleted file mode 100644 index 528dcd91d9..0000000000 --- a/packages/util/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "allowJs": true, - "noEmit": true, - "strict": true, - "isolatedModules": true - } -} From 685d79e953a02a3a78b91235c811932105574a66 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 10:49:47 -0400 Subject: [PATCH 131/154] feat(opencode): trace tool execution spans (#22531) --- packages/opencode/src/tool/tool.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 49dd2b0605..30be63a320 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -80,8 +80,14 @@ export namespace Tool { Effect.gen(function* () { const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init } const execute = toolInfo.execute - toolInfo.execute = (args, ctx) => - Effect.gen(function* () { + toolInfo.execute = (args, ctx) => { + const attrs = { + "tool.name": id, + "session.id": ctx.sessionID, + "message.id": ctx.messageID, + ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), + } + return Effect.gen(function* () { yield* Effect.try({ try: () => toolInfo.parameters.parse(args), catch: (error) => { @@ -109,7 +115,8 @@ export namespace Tool { ...(truncated.truncated && { outputPath: truncated.outputPath }), }, } - }).pipe(Effect.orDie) + }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) + } return toolInfo }) } From fe01fa7249f84100e97d97f346dcda4647e5bc5b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 10:55:50 -0400 Subject: [PATCH 132/154] remove makeRuntime facade from Env (#22523) --- packages/opencode/src/env/index.ts | 19 --- .../test/provider/amazon-bedrock.test.ts | 40 +++--- .../opencode/test/provider/provider.test.ts | 122 +++++++++--------- 3 files changed, 85 insertions(+), 96 deletions(-) diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 930287899c..b9efb68520 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -1,6 +1,5 @@ import { Context, Effect, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" export namespace Env { type State = Record @@ -35,22 +34,4 @@ export namespace Env { ) export const defaultLayer = layer - - const rt = makeRuntime(Service, defaultLayer) - - export function get(key: string) { - return rt.runSync((svc) => svc.get(key)) - } - - export function all() { - return rt.runSync((svc) => svc.all()) - } - - export function set(key: string, value: string) { - return rt.runSync((svc) => svc.set(key, value)) - } - - export function remove(key: string) { - return rt.runSync((svc) => svc.remove(key)) - } } diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 712f36086f..6783ff5889 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -11,6 +11,10 @@ import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" + +const env = makeRuntime(Env.Service, Env.defaultLayer) +const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) async function list() { return AppRuntime.runPromise( @@ -42,8 +46,8 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_REGION", "us-east-1") - Env.set("AWS_PROFILE", "default") + set("AWS_REGION", "us-east-1") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -67,8 +71,8 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_REGION", "eu-west-1") - Env.set("AWS_PROFILE", "default") + set("AWS_REGION", "eu-west-1") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -122,9 +126,9 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "") - Env.set("AWS_ACCESS_KEY_ID", "") - Env.set("AWS_BEARER_TOKEN_BEDROCK", "") + set("AWS_PROFILE", "") + set("AWS_ACCESS_KEY_ID", "") + set("AWS_BEARER_TOKEN_BEDROCK", "") }, fn: async () => { const providers = await list() @@ -168,8 +172,8 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") - Env.set("AWS_ACCESS_KEY_ID", "test-key-id") + set("AWS_PROFILE", "default") + set("AWS_ACCESS_KEY_ID", "test-key-id") }, fn: async () => { const providers = await list() @@ -200,7 +204,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () => await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -233,10 +237,10 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") - Env.set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") - Env.set("AWS_PROFILE", "") - Env.set("AWS_ACCESS_KEY_ID", "") + set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") + set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") + set("AWS_PROFILE", "") + set("AWS_ACCESS_KEY_ID", "") }, fn: async () => { const providers = await list() @@ -276,7 +280,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -313,7 +317,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -349,7 +353,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() @@ -385,7 +389,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("AWS_PROFILE", "default") + set("AWS_PROFILE", "default") }, fn: async () => { const providers = await list() diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index ac990bc0fb..dafa9dd822 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -13,6 +13,10 @@ import { Filesystem } from "../../src/util/filesystem" import { Env } from "../../src/env" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" + +const env = makeRuntime(Env.Service, Env.defaultLayer) +const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) async function run(fn: (provider: Provider.Interface) => Effect.Effect) { return AppRuntime.runPromise( @@ -71,7 +75,7 @@ test("provider loaded from env variable", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -126,7 +130,7 @@ test("disabled_providers excludes provider", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -150,8 +154,8 @@ test("enabled_providers restricts to only listed providers", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") - Env.set("OPENAI_API_KEY", "test-openai-key") + set("ANTHROPIC_API_KEY", "test-api-key") + set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { const providers = await list() @@ -180,7 +184,7 @@ test("model whitelist filters models for provider", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -211,7 +215,7 @@ test("model blacklist excludes specific models", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -246,7 +250,7 @@ test("custom model alias via config", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -322,7 +326,7 @@ test("env variable takes precedence, config merges options", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "env-api-key") + set("ANTHROPIC_API_KEY", "env-api-key") }, fn: async () => { const providers = await list() @@ -348,7 +352,7 @@ test("getModel returns model for valid provider/model", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) @@ -375,7 +379,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() @@ -428,7 +432,7 @@ test("defaultModel returns first available model when no config set", async () = await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model = await defaultModel() @@ -453,7 +457,7 @@ test("defaultModel respects config model setting", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model = await defaultModel() @@ -568,7 +572,7 @@ test("model options are merged from existing model", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -597,7 +601,7 @@ test("provider removed when all models filtered out", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -620,7 +624,7 @@ test("closest finds model by partial match", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const result = await closest(ProviderID.anthropic, ["sonnet-4"]) @@ -675,7 +679,7 @@ test("getModel uses realIdByKey for aliased models", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -790,7 +794,7 @@ test("model inherits properties from existing database model", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -818,7 +822,7 @@ test("disabled_providers prevents loading even with env var", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("OPENAI_API_KEY", "test-openai-key") + set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { const providers = await list() @@ -842,8 +846,8 @@ test("enabled_providers with empty array allows no providers", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") - Env.set("OPENAI_API_KEY", "test-openai-key") + set("ANTHROPIC_API_KEY", "test-api-key") + set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { const providers = await list() @@ -872,7 +876,7 @@ test("whitelist and blacklist can be combined", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -981,7 +985,7 @@ test("getSmallModel returns appropriate small model", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model = await getSmallModel(ProviderID.anthropic) @@ -1006,7 +1010,7 @@ test("getSmallModel respects config small_model override", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model = await getSmallModel(ProviderID.anthropic) @@ -1054,8 +1058,8 @@ test("multiple providers can be configured simultaneously", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-anthropic-key") - Env.set("OPENAI_API_KEY", "test-openai-key") + set("ANTHROPIC_API_KEY", "test-anthropic-key") + set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { const providers = await list() @@ -1133,7 +1137,7 @@ test("model alias name defaults to alias key when id differs", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1173,7 +1177,7 @@ test("provider with multiple env var options only includes apiKey when single en await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("MULTI_ENV_KEY_1", "test-key") + set("MULTI_ENV_KEY_1", "test-key") }, fn: async () => { const providers = await list() @@ -1215,7 +1219,7 @@ test("provider with single env var includes apiKey automatically", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("SINGLE_ENV_KEY", "my-api-key") + set("SINGLE_ENV_KEY", "my-api-key") }, fn: async () => { const providers = await list() @@ -1252,7 +1256,7 @@ test("model cost overrides existing cost values", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1331,9 +1335,9 @@ test("disabled_providers and enabled_providers interaction", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-anthropic") - Env.set("OPENAI_API_KEY", "test-openai") - Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") + set("ANTHROPIC_API_KEY", "test-anthropic") + set("OPENAI_API_KEY", "test-openai") + set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") }, fn: async () => { const providers = await list() @@ -1490,7 +1494,7 @@ test("provider env fallback - second env var used if first missing", async () => directory: tmp.path, init: async () => { // Only set fallback, not primary - Env.set("FALLBACK_KEY", "fallback-api-key") + set("FALLBACK_KEY", "fallback-api-key") }, fn: async () => { const providers = await list() @@ -1514,7 +1518,7 @@ test("getModel returns consistent results", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) @@ -1575,7 +1579,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { try { @@ -1603,7 +1607,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { try { @@ -1651,7 +1655,7 @@ test("getProvider returns provider info", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const provider = await getProvider(ProviderID.anthropic) @@ -1675,7 +1679,7 @@ test("closest returns undefined when no partial match found", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) @@ -1698,7 +1702,7 @@ test("closest checks multiple query terms in order", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { // First term won't match, second will @@ -1770,7 +1774,7 @@ test("provider options are deeply merged", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1808,7 +1812,7 @@ test("custom model inherits npm package from models.dev provider config", async await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("OPENAI_API_KEY", "test-api-key") + set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1843,7 +1847,7 @@ test("custom model inherits api.url from models.dev provider", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("OPENROUTER_API_KEY", "test-api-key") + set("OPENROUTER_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1944,7 +1948,7 @@ test("model variants are generated for reasoning models", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -1982,7 +1986,7 @@ test("model variants can be disabled via config", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2025,7 +2029,7 @@ test("model variants can be customized via config", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2064,7 +2068,7 @@ test("disabled key is stripped from variant config", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2102,7 +2106,7 @@ test("all variants can be disabled via config", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2140,7 +2144,7 @@ test("variant config merges with generated variants", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-api-key") + set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2178,7 +2182,7 @@ test("variants filtered in second pass for database models", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("OPENAI_API_KEY", "test-api-key") + set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { const providers = await list() @@ -2282,7 +2286,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { const providers = await list() @@ -2327,7 +2331,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { const providers = await list() @@ -2353,9 +2357,9 @@ test("cloudflare-ai-gateway loads with env variables", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("CLOUDFLARE_ACCOUNT_ID", "test-account") - Env.set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - Env.set("CLOUDFLARE_API_TOKEN", "test-token") + set("CLOUDFLARE_ACCOUNT_ID", "test-account") + set("CLOUDFLARE_GATEWAY_ID", "test-gateway") + set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { const providers = await list() @@ -2385,9 +2389,9 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("CLOUDFLARE_ACCOUNT_ID", "test-account") - Env.set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - Env.set("CLOUDFLARE_API_TOKEN", "test-token") + set("CLOUDFLARE_ACCOUNT_ID", "test-account") + set("CLOUDFLARE_GATEWAY_ID", "test-gateway") + set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { const providers = await list() @@ -2485,8 +2489,8 @@ test("plugin config enabled and disabled providers are honored", async () => { await Instance.provide({ directory: tmp.path, init: async () => { - Env.set("ANTHROPIC_API_KEY", "test-anthropic-key") - Env.set("OPENAI_API_KEY", "test-openai-key") + set("ANTHROPIC_API_KEY", "test-anthropic-key") + set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { const providers = await list() From 5fc656e2a0dff7e1cfb8baa40a63418673a1be48 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 10:57:58 -0400 Subject: [PATCH 133/154] docs(opencode): add instance context migration plan (#22529) --- .../opencode/specs/effect/instance-context.md | 310 ++++++++++++++++++ packages/opencode/specs/effect/migration.md | 4 + 2 files changed, 314 insertions(+) create mode 100644 packages/opencode/specs/effect/instance-context.md diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md new file mode 100644 index 0000000000..6c160a9477 --- /dev/null +++ b/packages/opencode/specs/effect/instance-context.md @@ -0,0 +1,310 @@ +# Instance context migration + +Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope. + +## Goal + +End state: + +- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)` +- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters +- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map>` +- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree + +## Current split + +Today `src/project/instance.ts` still owns two separate concerns: + +- ambient current-instance context through `LocalContext` / `AsyncLocalStorage` +- per-directory boot and deduplication through `cache: Map>` + +At the same time, the Effect side already exists: + +- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef` +- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context +- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed + +That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth". + +## End state shape + +Near-term target shape: + +```ts +InstanceScope.with({ directory, workspaceID }, effect) +``` + +Responsibilities of `InstanceScope.with(...)`: + +- resolve `directory`, `project`, and `worktree` +- acquire or reuse the scoped per-directory instance environment +- provide `InstanceRef` and `WorkspaceRef` +- run the caller's Effect inside that environment + +Code inside the boundary should then do one of these: + +```ts +const ctx = yield * InstanceState.context +const dir = yield * InstanceState.directory +``` + +Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order. + +## Migration phases + +### Phase 1: stop expanding the legacy surface + +Rules for all new code: + +- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code +- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet +- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code + +Success condition: + +- the file inventory below only shrinks from here + +### Phase 2: remove direct sync getter reads from Effect services + +Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access. + +Primary batch, highest payoff: + +- `src/file/index.ts` +- `src/lsp/server.ts` +- `src/worktree/index.ts` +- `src/file/watcher.ts` +- `src/format/formatter.ts` +- `src/session/index.ts` +- `src/project/vcs.ts` + +Mechanical replacement rule: + +- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory` +- `Instance.worktree` -> `ctx.worktree` +- `Instance.project` -> `ctx.project` + +Do not thread strings manually through every public method if the service already has access to Effect context. + +### Phase 3: convert entry boundaries to provide instance refs directly + +After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly. + +Main boundaries: + +- HTTP server middleware and experimental `HttpApi` entrypoints +- CLI commands +- TUI worker / attach / thread entrypoints +- tool execution entrypoints + +These boundaries should become Effect-native wrappers that: + +- decode directory / workspace inputs +- resolve the instance context once +- provide `InstanceRef` and `WorkspaceRef` +- run the requested Effect + +At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path. + +### Phase 4: replace promise boot cache with scoped instance runtime + +Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`. + +Target replacement: + +- keyed scoped runtime or keyed layer acquisition for each directory +- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager +- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map + +This phase should absorb the current responsibilities of: + +- `cache` in `src/project/instance.ts` +- `boot(...)` +- most of `disposeInstance(...)` +- manual `reload(...)` / `disposeAll()` fan-out logic + +### Phase 5: shrink ALS to callback bridges only + +Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously. + +Known bridge cases today: + +- `src/file/watcher.ts` +- `src/session/llm.ts` +- some LSP and plugin callback paths + +If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too. + +### Phase 6: delete the legacy sync API + +Only after earlier phases land: + +- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project` +- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely +- remove the ALS fallback from `InstanceState.context` + +## Inventory of direct legacy usage + +Direct legacy usage means any source file that still calls one of: + +- `Instance.current` +- `Instance.directory` +- `Instance.worktree` +- `Instance.project` +- `Instance.provide(...)` +- `Instance.bind(...)` +- `Instance.restore(...)` +- `Instance.reload(...)` +- `Instance.dispose()` / `Instance.disposeAll()` + +Current total: `54` files in `packages/opencode/src`. + +### Core bridge and plumbing + +These files define or adapt the current bridge. They should change last, after callers have moved. + +- `src/project/instance.ts` +- `src/effect/run-service.ts` +- `src/effect/instance-state.ts` +- `src/project/bootstrap.ts` +- `src/config/config.ts` + +Migration rule: + +- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS + +### HTTP and server boundaries + +These are the current request-entry seams that still create or consume instance context through the legacy helper. + +- `src/server/instance/middleware.ts` +- `src/server/instance/index.ts` +- `src/server/instance/project.ts` +- `src/server/instance/workspace.ts` +- `src/server/instance/file.ts` +- `src/server/instance/experimental.ts` +- `src/server/instance/global.ts` + +Migration rule: + +- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef` +- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS + +### CLI and TUI boundaries + +These commands still enter an instance through `Instance.provide(...)` or read sync getters directly. + +- `src/cli/bootstrap.ts` +- `src/cli/cmd/agent.ts` +- `src/cli/cmd/debug/agent.ts` +- `src/cli/cmd/debug/ripgrep.ts` +- `src/cli/cmd/github.ts` +- `src/cli/cmd/import.ts` +- `src/cli/cmd/mcp.ts` +- `src/cli/cmd/models.ts` +- `src/cli/cmd/plug.ts` +- `src/cli/cmd/pr.ts` +- `src/cli/cmd/providers.ts` +- `src/cli/cmd/stats.ts` +- `src/cli/cmd/tui/attach.ts` +- `src/cli/cmd/tui/plugin/runtime.ts` +- `src/cli/cmd/tui/thread.ts` +- `src/cli/cmd/tui/worker.ts` + +Migration rule: + +- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)` +- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider + +### Tool boundary code + +These tools mostly use direct getters for path resolution and repo-relative display logic. + +- `src/tool/apply_patch.ts` +- `src/tool/bash.ts` +- `src/tool/edit.ts` +- `src/tool/lsp.ts` +- `src/tool/multiedit.ts` +- `src/tool/plan.ts` +- `src/tool/read.ts` +- `src/tool/write.ts` + +Migration rule: + +- expose the current instance as an explicit Effect dependency for tool execution +- keep path logic local; avoid introducing another global singleton for tool state + +### Effect services still reading ambient instance state + +These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper. + +- `src/agent/agent.ts` +- `src/config/tui-migrate.ts` +- `src/file/index.ts` +- `src/file/watcher.ts` +- `src/format/formatter.ts` +- `src/lsp/client.ts` +- `src/lsp/index.ts` +- `src/lsp/server.ts` +- `src/mcp/index.ts` +- `src/project/vcs.ts` +- `src/provider/provider.ts` +- `src/pty/index.ts` +- `src/session/index.ts` +- `src/session/instruction.ts` +- `src/session/llm.ts` +- `src/session/system.ts` +- `src/sync/index.ts` +- `src/worktree/index.ts` + +Migration rule: + +- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx` +- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode + +### Highest-churn hotspots + +Current highest direct-usage counts by file: + +- `src/file/index.ts` - `18` +- `src/lsp/server.ts` - `14` +- `src/worktree/index.ts` - `12` +- `src/file/watcher.ts` - `9` +- `src/cli/cmd/mcp.ts` - `8` +- `src/format/formatter.ts` - `8` +- `src/tool/apply_patch.ts` - `8` +- `src/cli/cmd/github.ts` - `7` + +These files should drive the first measurable burn-down. + +## Recommended implementation order + +1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`. +2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`. +3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern. +4. Convert remaining CLI and tool boundaries. +5. Replace the promise cache with a keyed scoped runtime or keyed layer map. +6. Delete ALS fallback paths once only callback bridges still depend on them. + +## Definition of done + +This migration is done when all of the following are true: + +- new requests and commands enter an instance by providing Effect context, not ALS +- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` +- `Instance.provide(...)` is gone from normal request / CLI / tool execution +- per-directory boot and disposal are handled by scoped Effect resources +- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters + +## Tracker and worktree + +Active tracker items: + +- `lh7l73` - overall `HttpApi` migration +- `yobwlk` - remove direct `Instance.*` reads inside Effect services +- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers + +Dedicated worktree for this transition: + +- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift` +- branch: `kit/instance-effect-shift` diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index b8d4d12597..105a82290b 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -13,6 +13,10 @@ Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `Ma Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. +## Instance context transition + +See `instance-context.md` for the phased plan to remove the legacy ALS / promise-backed `Instance` helper and move request / CLI / tool boundaries onto Effect-provided instance scope. + ## Service shape Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions: From f06d82b6e8b98b77b0f8f63d824c800c6b99fa6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 15 Apr 2026 15:08:13 +0000 Subject: [PATCH 134/154] 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 0558d6b3d8..f860e3774e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-gdS7MkWGeVO0qLs0HKD156YE0uCk5vWeYjKu4JR1Apw=", - "aarch64-linux": "sha256-tF4pyVqzbrvdkRG23Fot37FCg8guRZkcU738fHPr/OQ=", - "aarch64-darwin": "sha256-FugTWzGMb2ktAbNwQvWRM3GWOb5RTR++8EocDDrQMLc=", - "x86_64-darwin": "sha256-jpe6EiwKr+CS00cn0eHwcDluO4LvO3t/5l/LcFBBKP0=" + "x86_64-linux": "sha256-3kpnjBg7AQanyDGTOFdYBFvo9O9Rfnu0Wmi8bY5LpEI=", + "aarch64-linux": "sha256-8rQ+SNUiSpA2Ea3NrYNGopHQsnY7Y8qBsXCqL6GMt24=", + "aarch64-darwin": "sha256-OASMkW5hnXucV6lSmxrQo73lGSEKN4MQPNGNV0i7jdo=", + "x86_64-darwin": "sha256-CmHqXlm8wnLcwSSK0ghxAf+DVurEltMaxrUbWh9/ZGE=" } } From f1751401aa2c53a4a0215c6deddf93df306aac8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 11:22:34 -0400 Subject: [PATCH 135/154] fix(effect): add effect bridge for callback contexts (#22504) --- bun.lock | 1 + packages/opencode/src/bus/index.ts | 5 +- packages/opencode/src/command/index.ts | 6 +- .../src/control-plane/workspace-context.ts | 4 + packages/opencode/src/effect/bridge.ts | 49 ++ packages/opencode/src/effect/run-service.ts | 23 +- packages/opencode/src/mcp/index.ts | 16 +- packages/opencode/src/plugin/index.ts | 23 +- packages/opencode/src/provider/provider.ts | 6 +- packages/opencode/src/pty/index.ts | 7 +- packages/opencode/src/session/llm.ts | 717 +++++++++--------- packages/opencode/src/session/prompt.ts | 7 +- .../test/effect/app-runtime-logger.test.ts | 31 + packages/server/package.json | 3 +- 14 files changed, 499 insertions(+), 399 deletions(-) create mode 100644 packages/opencode/src/effect/bridge.ts diff --git a/bun.lock b/bun.lock index 01966b826a..fe5d42d7cc 100644 --- a/bun.lock +++ b/bun.lock @@ -510,6 +510,7 @@ "effect": "catalog:", }, "devDependencies": { + "@typescript/native-preview": "catalog:", "typescript": "catalog:", }, }, diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 0638777bd4..3a1eea5c73 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectBridge } from "@/effect/bridge" import { Log } from "../util/log" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" @@ -128,6 +128,7 @@ export namespace Bus { function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { return Effect.gen(function* () { log.info("subscribing", { type }) + const bridge = yield* EffectBridge.make() const scope = yield* Scope.make() const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) @@ -147,7 +148,7 @@ export namespace Bus { return () => { log.info("unsubscribing", { type }) - Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer))) + bridge.fork(Scope.close(scope, Exit.void)) } }) } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 42f53301b2..91a9e1b405 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,9 +1,9 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context } from "effect" -import { EffectLogger } from "@/effect/logger" import z from "zod" import { Config } from "../config/config" import { MCP } from "../mcp" @@ -82,6 +82,7 @@ export namespace Command { const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() const commands: Record = {} commands[Default.INIT] = { @@ -125,7 +126,7 @@ export namespace Command { source: "mcp", description: prompt.description, get template() { - return Effect.runPromise( + return bridge.promise( mcp .getPrompt( prompt.client, @@ -141,7 +142,6 @@ export namespace Command { .map((message) => (message.content.type === "text" ? message.content.text : "")) .join("\n") || "", ), - Effect.provide(EffectLogger.layer), ), ) }, diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 173ec6178a..541657b88c 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -12,6 +12,10 @@ export const WorkspaceContext = { return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) }, + restore(workspaceID: string, fn: () => R): R { + return context.provide({ workspaceID }, fn) + }, + get workspaceID() { try { return context.use().workspaceID diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts new file mode 100644 index 0000000000..bafa5a0ea6 --- /dev/null +++ b/packages/opencode/src/effect/bridge.ts @@ -0,0 +1,49 @@ +import { Effect, Fiber } from "effect" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { Instance, type InstanceContext } from "@/project/instance" +import { LocalContext } from "@/util/local-context" +import { InstanceRef, WorkspaceRef } from "./instance-ref" +import { attachWith } from "./run-service" + +export namespace EffectBridge { + export interface Shape { + readonly promise: (effect: Effect.Effect) => Promise + readonly fork: (effect: Effect.Effect) => Fiber.Fiber + } + + function restore(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R { + if (instance && workspace !== undefined) { + return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) + } + if (instance) return Instance.restore(instance, fn) + if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) + return fn() + } + + export function make(): Effect.Effect { + return Effect.gen(function* () { + const ctx = yield* Effect.context() + const value = yield* InstanceRef + const instance = + value ?? + (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) + const wrap = (effect: Effect.Effect) => + attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect + + return { + promise: (effect: Effect.Effect) => + restore(instance, workspace, () => Effect.runPromise(wrap(effect))), + fork: (effect: Effect.Effect) => + restore(instance, workspace, () => Effect.runFork(wrap(effect))), + } satisfies Shape + }) + } +} diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index bb4307b57c..13104c88b3 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -5,14 +5,31 @@ import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { Observability } from "./observability" import { WorkspaceContext } from "@/control-plane/workspace-context" +import type { InstanceContext } from "@/project/instance" export const memoMap = Layer.makeMemoMapUnsafe() +type Refs = { + instance?: InstanceContext + workspace?: string +} + +export function attachWith(effect: Effect.Effect, refs: Refs): Effect.Effect { + if (!refs.instance && !refs.workspace) return effect + if (!refs.instance) return effect.pipe(Effect.provideService(WorkspaceRef, refs.workspace)) + if (!refs.workspace) return effect.pipe(Effect.provideService(InstanceRef, refs.instance)) + return effect.pipe( + Effect.provideService(InstanceRef, refs.instance), + Effect.provideService(WorkspaceRef, refs.workspace), + ) +} + export function attach(effect: Effect.Effect): Effect.Effect { try { - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID - return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)) + return attachWith(effect, { + instance: Instance.current, + workspace: WorkspaceContext.workspaceID, + }) } catch (err) { if (!(err instanceof LocalContext.NotFound)) throw err } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3b66909340..a68c6c1d8d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -25,7 +25,7 @@ import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" import { Effect, Exit, Layer, Option, Context, Stream } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -471,25 +471,24 @@ export namespace MCP { Effect.catch(() => Effect.succeed([] as number[])), ) - function watch(s: State, name: string, client: MCPClient, timeout?: number) { + function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { log.info("tools list changed notification received", { server: name }) if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer))) + const listed = await bridge.promise(defs(name, client, timeout)) if (!listed) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return s.defs[name] = listed - await Effect.runPromise( - bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)), - ) + await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } const state = yield* InstanceState.make( Effect.fn("MCP.state")(function* () { const cfg = yield* cfgSvc.get() + const bridge = yield* EffectBridge.make() const config = cfg.mcp ?? {} const s: State = { status: {}, @@ -518,7 +517,7 @@ export namespace MCP { if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! - watch(s, key, result.mcpClient, mcp.timeout) + watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), { concurrency: "unbounded" }, @@ -565,11 +564,12 @@ export namespace MCP { listed: MCPToolDef[], timeout?: number, ) { + const bridge = yield* EffectBridge.make() yield* closeClient(s, name) s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed - watch(s, name, client, timeout) + watch(s, name, client, bridge, timeout) return s.status[name] }) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index c716ffdf8d..9f618eff8c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -18,7 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { Effect, Layer, Context, Stream } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" @@ -90,14 +90,6 @@ export namespace Plugin { return result } - function publishPluginError(bus: Bus.Interface, message: string) { - Effect.runFork( - bus - .publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - .pipe(Effect.provide(EffectLogger.layer)), - ) - } - async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { @@ -120,6 +112,11 @@ export namespace Plugin { const state = yield* InstanceState.make( Effect.fn("Plugin.state")(function* (ctx) { const hooks: Hooks[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } const { Server } = yield* Effect.promise(() => import("../server/server")) @@ -187,24 +184,24 @@ export namespace Plugin { if (stage === "install") { const parsed = parsePluginSpecifier(spec) log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(bus, `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) return } if (stage === "compatibility") { log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(bus, `Plugin ${spec} skipped: ${message}`) + publishPluginError(`Plugin ${spec} skipped: ${message}`) return } if (stage === "entry") { log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) return } log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) }, }, }), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d34721f1d8..8833cfd05f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -19,7 +19,7 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Effect, Layer, Context } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" @@ -1043,6 +1043,7 @@ export namespace Provider { const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") + const bridge = yield* EffectBridge.make() const cfg = yield* config.get() const modelsDev = yield* Effect.promise(() => ModelsDev.get()) const database = mapValues(modelsDev, fromModelsDevProvider) @@ -1223,8 +1224,7 @@ export namespace Provider { const options = yield* Effect.promise(() => plugin.auth!.loader!( - () => - Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any, + () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, database[plugin.auth!.provider], ), ) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 9c79eb2d4c..1c969b4b93 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,7 +10,7 @@ import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectBridge } from "@/effect/bridge" export namespace Pty { const log = Log.create({ service: "pty" }) @@ -173,6 +173,7 @@ export namespace Pty { const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const s = yield* InstanceState.get(state) + const bridge = yield* EffectBridge.make() const id = PtyID.ascending() const command = input.command || Shell.preferred() const args = input.args || [] @@ -256,8 +257,8 @@ export namespace Pty { if (session.info.status === "exited") return log.info("session exited", { id, exitCode }) session.info.status = "exited" - Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer))) - Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer))) + bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(remove(id)) }), ) yield* bus.publish(Event.Created, { info }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 5a4c041196..05d7882757 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,13 +20,12 @@ import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" -import { makeRuntime } from "@/effect/run-service" +import { EffectBridge } from "@/effect/bridge" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" export namespace LLM { const log = Log.create({ service: "llm" }) - const perms = makeRuntime(Permission.Service, Permission.defaultLayer) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX type Result = Awaited> @@ -57,369 +56,371 @@ export namespace LLM { export class Service extends Context.Service()("@opencode/LLM") {} - export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Service - const config = yield* Config.Service - const provider = yield* Provider.Service - const plugin = yield* Plugin.Service + const live: Layer.Layer< + Service, + never, + Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service + > = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const config = yield* Config.Service + const provider = yield* Provider.Service + const plugin = yield* Plugin.Service + const perm = yield* Permission.Service - const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { - const l = log - .clone() - .tag("providerID", input.model.providerID) - .tag("modelID", input.model.id) - .tag("sessionID", input.sessionID) - .tag("small", (input.small ?? false).toString()) - .tag("agent", input.agent.name) - .tag("mode", input.agent.mode) - l.info("stream", { - modelID: input.model.id, - providerID: input.model.providerID, - }) - - const [language, cfg, item, info] = yield* Effect.all( - [ - provider.getLanguage(input.model), - config.get(), - provider.getProvider(input.model.providerID), - auth.get(input.model.providerID), - ], - { concurrency: "unbounded" }, - ) - - // TODO: move this to a proper hook - const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" - - const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), - // any custom prompt passed into this call - ...input.system, - // any custom prompt from last user message - ...(input.user.system ? [input.user.system] : []), - ] - .filter((x) => x) - .join("\n"), - ) - - const header = system[0] - yield* plugin.trigger( - "experimental.chat.system.transform", - { sessionID: input.sessionID, model: input.model }, - { system }, - ) - // rejoin to maintain 2-part structure for caching if header unchanged - if (system.length > 2 && system[0] === header) { - const rest = system.slice(1) - system.length = 0 - system.push(header, rest.join("\n")) - } - - const variant = - !input.small && input.model.variants && input.user.model.variant - ? input.model.variants[input.user.model.variant] - : {} - const base = input.small - ? ProviderTransform.smallOptions(input.model) - : ProviderTransform.options({ - model: input.model, - sessionID: input.sessionID, - providerOptions: item.options, - }) - const options: Record = pipe( - base, - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - mergeDeep(variant), - ) - if (isOpenaiOauth) { - options.instructions = system.join("\n") - } - - const isWorkflow = language instanceof GitLabWorkflowLanguageModel - const messages = isOpenaiOauth - ? input.messages - : isWorkflow - ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] - - const params = yield* plugin.trigger( - "chat.params", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider: item, - message: input.user, - }, - { - temperature: input.model.capabilities.temperature - ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) - : undefined, - topP: input.agent.topP ?? ProviderTransform.topP(input.model), - topK: ProviderTransform.topK(input.model), - maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), - options, - }, - ) - - const { headers } = yield* plugin.trigger( - "chat.headers", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider: item, - message: input.user, - }, - { - headers: {}, - }, - ) - - const tools = resolveTools(input) - - // LiteLLM and some Anthropic proxies require the tools parameter to be present - // when message history contains tool calls, even if no tools are being used. - // Add a dummy tool that is never called to satisfy this validation. - // This is enabled for: - // 1. Providers with "litellm" in their ID or API ID (auto-detected) - // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) - const isLiteLLMProxy = - item.options?.["litellmProxy"] === true || - input.model.providerID.toLowerCase().includes("litellm") || - input.model.api.id.toLowerCase().includes("litellm") - - // LiteLLM/Bedrock rejects requests where the message history contains tool - // calls but no tools param is present. When there are no active tools (e.g. - // during compaction), inject a stub tool to satisfy the validation requirement. - // The stub description explicitly tells the model not to call it. - if ( - (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) && - Object.keys(tools).length === 0 && - hasToolCalls(input.messages) - ) { - tools["_noop"] = tool({ - description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", - inputSchema: jsonSchema({ - type: "object", - properties: { - reason: { type: "string", description: "Unused" }, - }, - }), - execute: async () => ({ output: "", title: "", metadata: {} }), - }) - } - - // 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 as GitLabWorkflowLanguageModel & { - sessionID?: string - sessionPreapprovedTools?: string[] - approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> - } - workflowModel.sessionID = input.sessionID - workflowModel.systemPrompt = system.join("\n") - 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) } - } - } - - const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { - const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) - return !match || match.action !== "ask" - }) - - const approvedToolsForSession = new Set() - workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { - const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] - // Auto-approve tools that were already approved in this session - // (prevents infinite approval loops for server-side MCP tools) - if (uniqueNames.every((name) => approvedToolsForSession.has(name))) { - return { approved: true } - } - - const id = PermissionID.ascending() - let reply: Permission.Reply | undefined - let unsub: (() => void) | undefined - try { - unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { - if (evt.properties.requestID === id) reply = evt.properties.reply - }) - const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { - try { - const parsed = JSON.parse(t.args) as Record - const title = (parsed?.title ?? parsed?.name ?? "") as string - return title ? `${t.name}: ${title}` : t.name - } catch { - return t.name - } - }) - const uniquePatterns = [...new Set(toolPatterns)] as string[] - await perms.runPromise((svc) => - svc.ask({ - id, - sessionID: SessionID.make(input.sessionID), - permission: "workflow_tool_approval", - patterns: uniquePatterns, - metadata: { tools: approvalTools }, - always: uniquePatterns, - ruleset: [], - }), - ) - for (const name of uniqueNames) approvedToolsForSession.add(name) - workflowModel.sessionPreapprovedTools = [ - ...(workflowModel.sessionPreapprovedTools ?? []), - ...uniqueNames, - ] - return { approved: true } - } catch { - return { approved: false } - } finally { - unsub?.() - } - }) - } - - const tracer = cfg.experimental?.openTelemetry - ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) - : undefined - - return streamText({ - onError(error) { - l.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { - l.info("repairing tool call", { - tool: failed.toolCall.toolName, - repaired: lower, - }) - return { - ...failed.toolCall, - toolName: lower, - } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), - toolName: "invalid", - } - }, - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - tools, - toolChoice: input.toolChoice, - maxOutputTokens: params.maxOutputTokens, - abortSignal: input.abort, - headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : { - "x-session-affinity": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${Installation.VERSION}`, - }), - ...input.model.headers, - ...headers, - }, - maxRetries: input.retries ?? 0, - messages, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - specificationVersion: "v3" as const, - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - functionId: "session.llm", - tracer, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, - }, - }) + const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { + const l = log + .clone() + .tag("providerID", input.model.providerID) + .tag("modelID", input.model.id) + .tag("sessionID", input.sessionID) + .tag("small", (input.small ?? false).toString()) + .tag("agent", input.agent.name) + .tag("mode", input.agent.mode) + l.info("stream", { + modelID: input.model.id, + providerID: input.model.providerID, }) - const stream: Interface["stream"] = (input) => - Stream.scoped( - Stream.unwrap( - Effect.gen(function* () { - const ctrl = yield* Effect.acquireRelease( - Effect.sync(() => new AbortController()), - (ctrl) => Effect.sync(() => ctrl.abort()), - ) + const [language, cfg, item, info] = yield* Effect.all( + [ + provider.getLanguage(input.model), + config.get(), + provider.getProvider(input.model.providerID), + auth.get(input.model.providerID), + ], + { concurrency: "unbounded" }, + ) - const result = yield* run({ ...input, abort: ctrl.signal }) + // TODO: move this to a proper hook + const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" - return Stream.fromAsyncIterable(result.fullStream, (e) => - e instanceof Error ? e : new Error(String(e)), - ) + const system: string[] = [] + system.push( + [ + // use agent prompt otherwise provider prompt + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // any custom prompt passed into this call + ...input.system, + // any custom prompt from last user message + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + + const header = system[0] + yield* plugin.trigger( + "experimental.chat.system.transform", + { sessionID: input.sessionID, model: input.model }, + { system }, + ) + // rejoin to maintain 2-part structure for caching if header unchanged + if (system.length > 2 && system[0] === header) { + const rest = system.slice(1) + system.length = 0 + system.push(header, rest.join("\n")) + } + + const variant = + !input.small && input.model.variants && input.user.model.variant + ? input.model.variants[input.user.model.variant] + : {} + const base = input.small + ? ProviderTransform.smallOptions(input.model) + : ProviderTransform.options({ + model: input.model, + sessionID: input.sessionID, + providerOptions: item.options, + }) + const options: Record = pipe( + base, + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + mergeDeep(variant), + ) + if (isOpenaiOauth) { + options.instructions = system.join("\n") + } + + const isWorkflow = language instanceof GitLabWorkflowLanguageModel + const messages = isOpenaiOauth + ? input.messages + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] + + const params = yield* plugin.trigger( + "chat.params", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + provider: item, + message: input.user, + }, + { + temperature: input.model.capabilities.temperature + ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) + : undefined, + topP: input.agent.topP ?? ProviderTransform.topP(input.model), + topK: ProviderTransform.topK(input.model), + maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), + options, + }, + ) + + const { headers } = yield* plugin.trigger( + "chat.headers", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + provider: item, + message: input.user, + }, + { + headers: {}, + }, + ) + + const tools = resolveTools(input) + + // LiteLLM and some Anthropic proxies require the tools parameter to be present + // when message history contains tool calls, even if no tools are being used. + // Add a dummy tool that is never called to satisfy this validation. + // This is enabled for: + // 1. Providers with "litellm" in their ID or API ID (auto-detected) + // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) + const isLiteLLMProxy = + item.options?.["litellmProxy"] === true || + input.model.providerID.toLowerCase().includes("litellm") || + input.model.api.id.toLowerCase().includes("litellm") + + // LiteLLM/Bedrock rejects requests where the message history contains tool + // calls but no tools param is present. When there are no active tools (e.g. + // during compaction), inject a stub tool to satisfy the validation requirement. + // The stub description explicitly tells the model not to call it. + if ( + (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) && + Object.keys(tools).length === 0 && + hasToolCalls(input.messages) + ) { + tools["_noop"] = tool({ + description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + inputSchema: jsonSchema({ + type: "object", + properties: { + reason: { type: "string", description: "Unused" }, + }, + }), + execute: async () => ({ output: "", title: "", metadata: {} }), + }) + } + + // 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 as GitLabWorkflowLanguageModel & { + sessionID?: string + sessionPreapprovedTools?: string[] + approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> + } + workflowModel.sessionID = input.sessionID + workflowModel.systemPrompt = system.join("\n") + 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) } + } + } + + const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) + workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { + const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) + return !match || match.action !== "ask" + }) + + const bridge = yield* EffectBridge.make() + const approvedToolsForSession = new Set() + workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { + const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] + // Auto-approve tools that were already approved in this session + // (prevents infinite approval loops for server-side MCP tools) + if (uniqueNames.every((name) => approvedToolsForSession.has(name))) { + return { approved: true } + } + + const id = PermissionID.ascending() + let reply: Permission.Reply | undefined + let unsub: (() => void) | undefined + try { + unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { + if (evt.properties.requestID === id) reply = evt.properties.reply + }) + const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { + try { + const parsed = JSON.parse(t.args) as Record + const title = (parsed?.title ?? parsed?.name ?? "") as string + return title ? `${t.name}: ${title}` : t.name + } catch { + return t.name + } + }) + const uniquePatterns = [...new Set(toolPatterns)] as string[] + await bridge.promise( + perm.ask({ + id, + sessionID: SessionID.make(input.sessionID), + permission: "workflow_tool_approval", + patterns: uniquePatterns, + metadata: { tools: approvalTools }, + always: uniquePatterns, + ruleset: [], + }), + ) + for (const name of uniqueNames) approvedToolsForSession.add(name) + workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames] + return { approved: true } + } catch { + return { approved: false } + } finally { + unsub?.() + } + }) + } + + const tracer = cfg.experimental?.openTelemetry + ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) + : undefined + + return streamText({ + onError(error) { + l.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && tools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, }), - ), - ) + toolName: "invalid", + } + }, + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + toolChoice: input.toolChoice, + maxOutputTokens: params.maxOutputTokens, + abortSignal: input.abort, + headers: { + ...(input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : { + "x-session-affinity": input.sessionID, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": `opencode/${Installation.VERSION}`, + }), + ...input.model.headers, + ...headers, + }, + maxRetries: input.retries ?? 0, + messages, + model: wrapLanguageModel({ + model: language, + middleware: [ + { + specificationVersion: "v3" as const, + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + functionId: "session.llm", + tracer, + metadata: { + userId: cfg.username ?? "unknown", + sessionId: input.sessionID, + }, + }, + }) + }) - return Service.of({ stream }) - }), - ) + const stream: Interface["stream"] = (input) => + Stream.scoped( + Stream.unwrap( + Effect.gen(function* () { + const ctrl = yield* Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (ctrl) => Effect.sync(() => ctrl.abort()), + ) + + const result = yield* run({ ...input, abort: ctrl.signal }) + + return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))) + }), + ), + ) + + return Service.of({ stream }) + }), + ) + + export const layer = live.pipe(Layer.provide(Permission.defaultLayer)) export const defaultLayer = Layer.suspend(() => layer.pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8c794505e..ffd074d3f8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,6 +48,7 @@ import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" +import { EffectBridge } from "@/effect/bridge" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -105,11 +106,7 @@ export namespace SessionPrompt { const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { - const ctx = yield* Effect.context() - return { - promise: (effect: Effect.Effect) => Effect.runPromiseWith(ctx)(effect), - fork: (effect: Effect.Effect) => Effect.runForkWith(ctx)(effect), - } + return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { const run = yield* runner() diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index 8a7aab6cf8..7388748f92 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { Context, Effect, Layer, Logger } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { EffectBridge } from "../../src/effect/bridge" import { InstanceRef } from "../../src/effect/instance-ref" import { EffectLogger } from "../../src/effect/logger" import { makeRuntime } from "../../src/effect/run-service" @@ -59,3 +60,33 @@ test("AppRuntime attaches InstanceRef from ALS", async () => { expect(dir).toBe(tmp.path) }) + +test("EffectBridge preserves logger and instance context across async boundaries", async () => { + await using tmp = await tmpdir({ git: true }) + + const result = await Instance.provide({ + directory: tmp.path, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + return yield* Effect.promise(() => + Promise.resolve().then(() => + bridge.promise( + Effect.gen(function* () { + return { + directory: (yield* InstanceRef)?.directory, + ...check(yield* Effect.service(Logger.CurrentLoggers)), + } + }), + ), + ), + ) + }), + ), + }) + + expect(result.directory).toBe(tmp.path) + expect(result.effectLogger).toBe(true) + expect(result.defaultLogger).toBe(false) +}) diff --git a/packages/server/package.json b/packages/server/package.json index c397c40d90..9b8b31299d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,10 +17,11 @@ "dist" ], "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "build": "tsc" }, "devDependencies": { + "@typescript/native-preview": "catalog:", "typescript": "catalog:" }, "dependencies": { From 4ae7c77f8abda8d51ddf52ee6e07890fa19b6629 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 15 Apr 2026 11:50:24 -0400 Subject: [PATCH 136/154] migrate: move flock and hash utilities to shared package (#22640) --- bun.lock | 2 + packages/opencode/src/acp/agent.ts | 2 +- .../src/cli/cmd/tui/plugin/runtime.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/global/index.ts | 4 + packages/opencode/src/npm/index.ts | 2 +- packages/opencode/src/plugin/install.ts | 2 +- packages/opencode/src/plugin/meta.ts | 2 +- packages/opencode/src/provider/models.ts | 4 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/snapshot/index.ts | 2 +- .../opencode/test/fixture/flock-worker.ts | 2 +- packages/shared/package.json | 8 +- packages/shared/src/global.ts | 42 +++ packages/shared/src/npm.ts | 247 +++++++++++++ packages/shared/src/types.d.ts | 44 +++ .../{opencode => shared}/src/util/flock.ts | 29 +- .../{opencode => shared}/src/util/hash.ts | 0 .../shared/test/filesystem/filesystem.test.ts | 338 ++++++++++++++++++ packages/shared/test/fixture/flock-worker.ts | 72 ++++ packages/shared/test/lib/effect.ts | 53 +++ packages/shared/test/npm.test.ts | 18 + .../test/util/flock.test.ts | 91 +++-- 23 files changed, 929 insertions(+), 41 deletions(-) create mode 100644 packages/shared/src/global.ts create mode 100644 packages/shared/src/npm.ts create mode 100644 packages/shared/src/types.d.ts rename packages/{opencode => shared}/src/util/flock.ts (93%) rename packages/{opencode => shared}/src/util/hash.ts (100%) create mode 100644 packages/shared/test/filesystem/filesystem.test.ts create mode 100644 packages/shared/test/fixture/flock-worker.ts create mode 100644 packages/shared/test/lib/effect.ts create mode 100644 packages/shared/test/npm.test.ts rename packages/{opencode => shared}/test/util/flock.test.ts (80%) diff --git a/bun.lock b/bun.lock index fe5d42d7cc..a6f9891dd1 100644 --- a/bun.lock +++ b/bun.lock @@ -527,9 +527,11 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "semver": "catalog:", + "xdg-basedir": "5.1.0", "zod": "catalog:", }, "devDependencies": { + "@types/bun": "catalog:", "@types/semver": "catalog:", }, }, diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 09f8663ed0..8ac09e4bb3 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -34,7 +34,7 @@ import { import { Log } from "../util/log" import { pathToFileURL } from "url" import { Filesystem } from "../util/filesystem" -import { Hash } from "../util/hash" +import { Hash } from "@opencode-ai/shared/util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2f7fd51643..7f12106b2c 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -34,7 +34,7 @@ import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" -import { Flock } from "@/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f8205bac26..915e604e90 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -34,7 +34,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" -import { Flock } from "@/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "../npm" import { InstanceRef } from "@/effect/instance-ref" diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 869019e2ce..32d5153213 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -3,6 +3,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" import { Filesystem } from "../util/filesystem" +import { Flock } from "@opencode-ai/shared/util/flock" const app = "opencode" @@ -26,6 +27,9 @@ export namespace Global { } } +// Initialize Flock with global state path +Flock.setGlobal({ state }) + await Promise.all([ fs.mkdir(Global.Path.data, { recursive: true }), fs.mkdir(Global.Path.config, { recursive: true }), diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 5b708431c6..e648fd899c 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -6,7 +6,7 @@ import { Log } from "../util/log" import path from "path" import { readdir, rm } from "fs/promises" import { Filesystem } from "@/util/filesystem" -import { Flock } from "@/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" import { Arborist } from "@npmcli/arborist" export namespace Npm { diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index b6bac42a7f..8dd8212965 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -10,7 +10,7 @@ import { import { ConfigPaths } from "@/config/paths" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -import { Flock } from "@/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" import { isRecord } from "@/util/record" import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared" diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index cbfaf6ae15..f408954690 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -import { Flock } from "@/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" import { parsePluginSpecifier, pluginSource } from "./shared" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 2d787588b0..55f137aa0b 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -6,8 +6,8 @@ import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "../util/filesystem" -import { Flock } from "@/util/flock" -import { Hash } from "@/util/hash" +import { Flock } from "@opencode-ai/shared/util/flock" +import { Hash } from "@opencode-ai/shared/util/hash" // Try to import bundled snapshot (generated at build time) // Falls back to undefined in dev mode when snapshot doesn't exist diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8833cfd05f..9ec5dfc6b5 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -6,7 +6,7 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" import { Log } from "../util/log" import { Npm } from "../npm" -import { Hash } from "../util/hash" +import { Hash } from "@opencode-ai/shared/util/hash" import { Plugin } from "../plugin" import { NamedError } from "@opencode-ai/shared/util/error" import { type LanguageModelV3 } from "@ai-sdk/provider" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 2b21f7e895..9378e309aa 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -6,7 +6,7 @@ import z from "zod" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Hash } from "@/util/hash" +import { Hash } from "@opencode-ai/shared/util/hash" import { Config } from "../config/config" import { Global } from "../global" import { Log } from "../util/log" diff --git a/packages/opencode/test/fixture/flock-worker.ts b/packages/opencode/test/fixture/flock-worker.ts index ac05fe810c..9954d290cc 100644 --- a/packages/opencode/test/fixture/flock-worker.ts +++ b/packages/opencode/test/fixture/flock-worker.ts @@ -1,5 +1,5 @@ import fs from "fs/promises" -import { Flock } from "../../src/util/flock" +import { Flock } from "@opencode-ai/shared/util/flock" type Msg = { key: string diff --git a/packages/shared/package.json b/packages/shared/package.json index 1bb1ca47ef..252b381d48 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,7 +5,9 @@ "type": "module", "license": "MIT", "private": true, - "scripts": {}, + "scripts": { + "test": "bun test" + }, "bin": { "opencode": "./bin/opencode" }, @@ -14,7 +16,8 @@ }, "imports": {}, "devDependencies": { - "@types/semver": "catalog:" + "@types/semver": "catalog:", + "@types/bun": "catalog:" }, "dependencies": { "@effect/platform-node": "catalog:", @@ -23,6 +26,7 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "semver": "catalog:", + "xdg-basedir": "5.1.0", "zod": "catalog:" }, "overrides": { diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts new file mode 100644 index 0000000000..538cc091b5 --- /dev/null +++ b/packages/shared/src/global.ts @@ -0,0 +1,42 @@ +import path from "path" +import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" +import os from "os" +import { Context, Effect, Layer } from "effect" + +export namespace Global { + export class Service extends Context.Service()("@opencode/Global") {} + + export interface Interface { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly bin: string + readonly log: string + } + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const app = "opencode" + const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() + const data = path.join(xdgData!, app) + const cache = path.join(xdgCache!, app) + const cfg = path.join(xdgConfig!, app) + const state = path.join(xdgState!, app) + const bin = path.join(cache, "bin") + const log = path.join(data, "log") + + return Service.of({ + home, + data, + cache, + config: cfg, + state, + bin, + log, + }) + }), + ) +} diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts new file mode 100644 index 0000000000..994ec04dae --- /dev/null +++ b/packages/shared/src/npm.ts @@ -0,0 +1,247 @@ +import path from "path" +import semver from "semver" +import { Arborist } from "@npmcli/arborist" +import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" +import { Flock } from "./util/flock" + +export namespace Npm { + export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { + pkg: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export interface EntryPoint { + readonly directory: string + readonly entrypoint: Option.Option + } + + export interface Interface { + readonly add: (pkg: string) => Effect.Effect + readonly install: (dir: string) => Effect.Effect + readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect + readonly which: (pkg: string) => Effect.Effect> + } + + export class Service extends Context.Service()("@opencode/Npm") {} + + const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined + + export function sanitize(pkg: string) { + if (!illegal) return pkg + return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") + } + + const resolveEntryPoint = (name: string, dir: string): EntryPoint => { + let entrypoint: Option.Option + try { + const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) + entrypoint = Option.some(resolved) + } catch { + entrypoint = Option.none() + } + return { + directory: dir, + entrypoint, + } + } + + interface ArboristNode { + name: string + path: string + } + + interface ArboristTree { + edgesOut: Map + } + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const afs = yield* AppFileSystem.Service + const global = yield* Global.Service + const fs = yield* FileSystem.FileSystem + const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + + const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { + const response = yield* Effect.tryPromise({ + try: () => fetch(`https://registry.npmjs.org/${pkg}`), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + + if (!response || !response.ok) { + return false + } + + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + + const latestVersion = data?.["dist-tags"]?.latest + if (!latestVersion) { + return false + } + + const range = /[\s^~*xX<>|=]/.test(cachedVersion) + if (range) return !semver.satisfies(latestVersion, cachedVersion) + + return semver.lt(cachedVersion, latestVersion) + }) + + const add = Effect.fn("Npm.add")(function* (pkg: string) { + const dir = directory(pkg) + yield* Flock.effect(`npm-install:${dir}`) + + const arborist = new Arborist({ + path: dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + + const tree = yield* Effect.tryPromise({ + try: () => arborist.loadVirtual().catch(() => undefined), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) as Effect.Effect + + if (tree) { + const first = tree.edgesOut.values().next().value?.to + if (first) { + return resolveEntryPoint(first.name, first.path) + } + } + + const result = yield* Effect.tryPromise({ + try: () => + arborist.reify({ + add: [pkg], + save: true, + saveType: "prod", + }), + catch: (cause) => new InstallFailedError({ pkg, cause }), + }) as Effect.Effect + + const first = result.edgesOut.values().next().value?.to + if (!first) { + return yield* new InstallFailedError({ pkg }) + } + + return resolveEntryPoint(first.name, first.path) + }, Effect.scoped) + + const install = Effect.fn("Npm.install")(function* (dir: string) { + yield* Flock.effect(`npm-install:${dir}`) + + const reify = Effect.fnUntraced(function* () { + const arb = new Arborist({ + path: dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + yield* Effect.tryPromise({ + try: () => arb.reify().catch(() => {}), + catch: () => {}, + }).pipe(Effect.orElseSucceed(() => {})) + }) + + const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) + if (!nodeModulesExists) { + yield* reify() + return + } + + const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({}))) + const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({}))) + + const pkgAny = pkg as any + const lockAny = lock as any + + const declared = new Set([ + ...Object.keys(pkgAny?.dependencies || {}), + ...Object.keys(pkgAny?.devDependencies || {}), + ...Object.keys(pkgAny?.peerDependencies || {}), + ...Object.keys(pkgAny?.optionalDependencies || {}), + ]) + + const root = lockAny?.packages?.[""] || {} + const locked = new Set([ + ...Object.keys(root?.dependencies || {}), + ...Object.keys(root?.devDependencies || {}), + ...Object.keys(root?.peerDependencies || {}), + ...Object.keys(root?.optionalDependencies || {}), + ]) + + for (const name of declared) { + if (!locked.has(name)) { + yield* reify() + return + } + } + }, Effect.scoped) + + const which = Effect.fn("Npm.which")(function* (pkg: string) { + const dir = directory(pkg) + const binDir = path.join(dir, "node_modules", ".bin") + + const pick = Effect.fnUntraced(function* () { + const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + if (files.length === 0) return Option.none() + if (files.length === 1) return Option.some(files[0]) + + const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) + + if (Option.isSome(pkgJson)) { + const parsed = pkgJson.value as { bin?: string | Record } + if (parsed?.bin) { + const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg + const bin = parsed.bin + if (typeof bin === "string") return Option.some(unscoped) + const keys = Object.keys(bin) + if (keys.length === 1) return Option.some(keys[0]) + return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) + } + } + + return Option.some(files[0]) + }) + + return yield* Effect.gen(function* () { + const bin = yield* pick() + if (Option.isSome(bin)) { + return Option.some(path.join(binDir, bin.value)) + } + + yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) + + yield* add(pkg) + + const resolved = yield* pick() + if (Option.isNone(resolved)) return Option.none() + return Option.some(path.join(binDir, resolved.value)) + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => Option.none()), + ) + }) + + return Service.of({ + add, + install, + outdated, + which, + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), + ) +} diff --git a/packages/shared/src/types.d.ts b/packages/shared/src/types.d.ts new file mode 100644 index 0000000000..b5d667f1d9 --- /dev/null +++ b/packages/shared/src/types.d.ts @@ -0,0 +1,44 @@ +declare module "@npmcli/arborist" { + export interface ArboristOptions { + path: string + binLinks?: boolean + progress?: boolean + savePrefix?: string + ignoreScripts?: boolean + } + + export interface ArboristNode { + name: string + path: string + } + + export interface ArboristEdge { + to?: ArboristNode + } + + export interface ArboristTree { + edgesOut: Map + } + + export interface ReifyOptions { + add?: string[] + save?: boolean + saveType?: "prod" | "dev" | "optional" | "peer" + } + + export class Arborist { + constructor(options: ArboristOptions) + loadVirtual(): Promise + reify(options?: ReifyOptions): Promise + } +} + +declare var Bun: + | { + file(path: string): { + text(): Promise + json(): Promise + } + write(path: string, content: string | Uint8Array): Promise + } + | undefined diff --git a/packages/opencode/src/util/flock.ts b/packages/shared/src/util/flock.ts similarity index 93% rename from packages/opencode/src/util/flock.ts rename to packages/shared/src/util/flock.ts index 74c7905ebb..4a1df1dee7 100644 --- a/packages/opencode/src/util/flock.ts +++ b/packages/shared/src/util/flock.ts @@ -2,11 +2,25 @@ import path from "path" import os from "os" import { randomBytes, randomUUID } from "crypto" import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises" -import { Global } from "@/global" -import { Hash } from "@/util/hash" +import { Hash } from "./hash" +import { Effect } from "effect" + +export type FlockGlobal = { + state: string +} export namespace Flock { - const root = path.join(Global.Path.state, "locks") + let global: FlockGlobal | undefined + + export function setGlobal(g: FlockGlobal) { + global = g + } + + const root = () => { + if (!global) throw new Error("Flock global not set") + return path.join(global.state, "locks") + } + // Defaults for callers that do not provide timing options. const defaultOpts = { staleMs: 60_000, @@ -301,7 +315,7 @@ export namespace Flock { baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs, maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs, } - const dir = input.dir ?? root + const dir = input.dir ?? root() await mkdir(dir, { recursive: true }) const lockfile = path.join(dir, Hash.fast(key) + ".lock") @@ -330,4 +344,11 @@ export namespace Flock { input.signal?.throwIfAborted() return await fn() } + + export const effect = Effect.fn("Flock.effect")(function* (key: string) { + return yield* Effect.acquireRelease( + Effect.promise((signal) => Flock.acquire(key, { signal })), + (foo) => Effect.promise(() => foo.release()), + ).pipe(Effect.asVoid) + }) } diff --git a/packages/opencode/src/util/hash.ts b/packages/shared/src/util/hash.ts similarity index 100% rename from packages/opencode/src/util/hash.ts rename to packages/shared/src/util/hash.ts diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts new file mode 100644 index 0000000000..ce990d3795 --- /dev/null +++ b/packages/shared/test/filesystem/filesystem.test.ts @@ -0,0 +1,338 @@ +import { describe, test, expect } from "bun:test" +import { Effect, Layer, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { testEffect } from "../lib/effect" +import path from "path" + +const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const { effect: it } = testEffect(live) + +describe("AppFileSystem", () => { + describe("isDir", () => { + it( + "returns true for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isDir(tmp)).toBe(true) + }), + ) + + it( + "returns false for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isDir(file)).toBe(false) + }), + ) + + it( + "returns false for non-existent paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) + }), + ) + }) + + describe("isFile", () => { + it( + "returns true for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isFile(file)).toBe(true) + }), + ) + + it( + "returns false for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isFile(tmp)).toBe(false) + }), + ) + }) + + describe("readJson / writeJson", () => { + it( + "round-trips JSON data", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "data.json") + const data = { name: "test", count: 42, nested: { ok: true } } + + yield* fs.writeJson(file, data) + const result = yield* fs.readJson(file) + + expect(result).toEqual(data) + }), + ) + }) + + describe("ensureDir", () => { + it( + "creates nested directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const nested = path.join(tmp, "a", "b", "c") + + yield* fs.ensureDir(nested) + + const info = yield* filesys.stat(nested) + expect(info.type).toBe("Directory") + }), + ) + + it( + "is idempotent", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const dir = path.join(tmp, "existing") + yield* filesys.makeDirectory(dir) + + yield* fs.ensureDir(dir) + + const info = yield* filesys.stat(dir) + expect(info.type).toBe("Directory") + }), + ) + }) + + describe("writeWithDirs", () => { + it( + "creates parent directories if missing", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "deep", "nested", "file.txt") + + yield* fs.writeWithDirs(file, "hello") + + expect(yield* filesys.readFileString(file)).toBe("hello") + }), + ) + + it( + "writes directly when parent exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "direct.txt") + + yield* fs.writeWithDirs(file, "world") + + expect(yield* filesys.readFileString(file)).toBe("world") + }), + ) + + it( + "writes Uint8Array content", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "binary.bin") + const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + + yield* fs.writeWithDirs(file, content) + + const result = yield* filesys.readFile(file) + expect(new Uint8Array(result)).toEqual(content) + }), + ) + }) + + describe("findUp", () => { + it( + "finds target in start directory", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found") + + const result = yield* fs.findUp("target.txt", tmp) + expect(result).toEqual([path.join(tmp, "target.txt")]) + }), + ) + + it( + "finds target in parent directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + + const result = yield* fs.findUp("marker", child, tmp) + expect(result).toEqual([path.join(tmp, "marker")]) + }), + ) + + it( + "returns empty array when not found", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const result = yield* fs.findUp("nonexistent", tmp, tmp) + expect(result).toEqual([]) + }), + ) + }) + + describe("up", () => { + it( + "finds multiple targets walking up", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b") + const child = path.join(tmp, "sub") + yield* filesys.makeDirectory(child) + yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child") + + const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) + + expect(result).toContain(path.join(child, "a.txt")) + expect(result).toContain(path.join(tmp, "a.txt")) + expect(result).toContain(path.join(tmp, "b.txt")) + }), + ) + }) + + describe("glob", () => { + it( + "finds files matching pattern", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b") + yield* filesys.writeFileString(path.join(tmp, "c.json"), "c") + + const result = yield* fs.glob("*.ts", { cwd: tmp }) + expect(result.sort()).toEqual(["a.ts", "b.ts"]) + }), + ) + + it( + "supports absolute paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello") + + const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) + expect(result).toEqual([path.join(tmp, "file.txt")]) + }), + ) + }) + + describe("globMatch", () => { + it( + "matches patterns", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) + expect(fs.globMatch("*.ts", "foo.json")).toBe(false) + expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) + }), + ) + }) + + describe("globUp", () => { + it( + "finds files walking up directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "root.md"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf") + + const result = yield* fs.globUp("*.md", child, tmp) + expect(result).toContain(path.join(child, "leaf.md")) + expect(result).toContain(path.join(tmp, "root.md")) + }), + ) + }) + + describe("built-in passthrough", () => { + it( + "exists works", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "yes") + + expect(yield* filesys.exists(file)).toBe(true) + expect(yield* filesys.exists(file + ".nope")).toBe(false) + }), + ) + + it( + "remove works", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "delete-me.txt") + yield* filesys.writeFileString(file, "bye") + + yield* filesys.remove(file) + + expect(yield* filesys.exists(file)).toBe(false) + }), + ) + }) + + describe("pure helpers", () => { + test("mimeType returns correct types", () => { + expect(AppFileSystem.mimeType("file.json")).toBe("application/json") + expect(AppFileSystem.mimeType("image.png")).toBe("image/png") + expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") + }) + + test("contains checks path containment", () => { + expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) + }) + + test("overlaps detects overlapping paths", () => { + expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) + expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) + }) + }) +}) diff --git a/packages/shared/test/fixture/flock-worker.ts b/packages/shared/test/fixture/flock-worker.ts new file mode 100644 index 0000000000..9954d290cc --- /dev/null +++ b/packages/shared/test/fixture/flock-worker.ts @@ -0,0 +1,72 @@ +import fs from "fs/promises" +import { Flock } from "@opencode-ai/shared/util/flock" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function input() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Missing flock worker input") + } + + return JSON.parse(raw) as Msg +} + +async function job(input: Msg) { + if (input.ready) { + await fs.writeFile(input.ready, String(process.pid)) + } + + if (input.active) { + await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) + } + + try { + if (input.holdMs && input.holdMs > 0) { + await sleep(input.holdMs) + } + + if (input.done) { + await fs.appendFile(input.done, "1\n") + } + } finally { + if (input.active) { + await fs.rm(input.active, { force: true }) + } + } +} + +async function main() { + const msg = input() + + await Flock.withLock(msg.key, () => job(msg), { + dir: msg.dir, + staleMs: msg.staleMs, + timeoutMs: msg.timeoutMs, + baseDelayMs: msg.baseDelayMs, + maxDelayMs: msg.maxDelayMs, + }) +} + +await main().catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/shared/test/lib/effect.ts b/packages/shared/test/lib/effect.ts new file mode 100644 index 0000000000..131ec5cc6b --- /dev/null +++ b/packages/shared/test/lib/effect.ts @@ -0,0 +1,53 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +// Test environment with TestClock and TestConsole +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +// Live environment - uses real clock, but keeps TestConsole for output capture +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/shared/test/npm.test.ts b/packages/shared/test/npm.test.ts new file mode 100644 index 0000000000..4443d2985c --- /dev/null +++ b/packages/shared/test/npm.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { Npm } from "@opencode-ai/shared/npm" + +const win = process.platform === "win32" + +describe("Npm.sanitize", () => { + test("keeps normal scoped package specs unchanged", () => { + expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") + expect(Npm.sanitize("prettier")).toBe("prettier") + }) + + test("handles git https specs", () => { + const spec = "acme@git+https://github.com/opencode/acme.git" + const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec + expect(Npm.sanitize(spec)).toBe(expected) + }) +}) diff --git a/packages/opencode/test/util/flock.test.ts b/packages/shared/test/util/flock.test.ts similarity index 80% rename from packages/opencode/test/util/flock.test.ts rename to packages/shared/test/util/flock.test.ts index fedbfb0697..f1053dfd2b 100644 --- a/packages/opencode/test/util/flock.test.ts +++ b/packages/shared/test/util/flock.test.ts @@ -1,14 +1,10 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" +import { spawn } from "child_process" import path from "path" -import { Flock } from "../../src/util/flock" -import { Hash } from "../../src/util/hash" -import { Process } from "../../src/util/process" -import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" - -const root = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") +import os from "os" +import { Flock } from "@opencode-ai/shared/util/flock" +import { Hash } from "@opencode-ai/shared/util/hash" type Msg = { key: string @@ -23,6 +19,19 @@ type Msg = { done?: string } +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") + +async function tmpdir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-")) + return { + path: dir, + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} + function lock(dir: string, key: string) { return path.join(dir, Hash.fast(key) + ".lock") } @@ -51,21 +60,55 @@ async function wait(file: string, timeout = 3_000) { } function run(msg: Msg) { - return Process.run([process.execPath, worker, JSON.stringify(msg)], { - cwd: root, - nothrow: true, + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + }) + + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + + proc.on("close", (code) => { + resolve({ + code: code ?? 1, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }) + }) }) } -function spawn(msg: Msg) { - return Process.spawn([process.execPath, worker, JSON.stringify(msg)], { +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + stdio: ["ignore", "pipe", "pipe"], }) } +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + describe("util.flock", () => { test("enforces mutual exclusion under process contention", async () => { await using tmp = await tmpdir() @@ -104,7 +147,7 @@ describe("util.flock", () => { const dir = path.join(tmp.path, "locks") const key = "flock:timeout" const ready = path.join(tmp.path, "ready") - const proc = spawn({ + const proc = spawnWorker({ key, dir, ready, @@ -131,8 +174,8 @@ describe("util.flock", () => { expect(seen.length).toBeGreaterThan(0) expect(seen.every((x) => x === key)).toBe(true) } finally { - await Process.stop(proc).catch(() => undefined) - await proc.exited.catch(() => undefined) + await stopWorker(proc).catch(() => undefined) + await new Promise((resolve) => proc.on("close", resolve)) } }, 15_000) @@ -141,7 +184,7 @@ describe("util.flock", () => { const dir = path.join(tmp.path, "locks") const key = "flock:crash" const ready = path.join(tmp.path, "ready") - const proc = spawn({ + const proc = spawnWorker({ key, dir, ready, @@ -151,8 +194,8 @@ describe("util.flock", () => { }) await wait(ready, 5_000) - await Process.stop(proc) - await proc.exited.catch(() => undefined) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) let hit = false await Flock.withLock( @@ -276,7 +319,7 @@ describe("util.flock", () => { await Flock.withLock( key, async () => { - const json = await Filesystem.readJson<{ + const json = await readJson<{ token?: unknown pid?: unknown hostname?: unknown @@ -324,7 +367,7 @@ describe("util.flock", () => { const err = await Flock.withLock( key, async () => { - const json = await Filesystem.readJson<{ token?: string }>(meta) + const json = await readJson<{ token?: string }>(meta) json.token = "tampered" await fs.writeFile(meta, JSON.stringify(json, null, 2)) }, From 9640d889baa58fa01ed612a6372ba77462f79d9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 12:35:14 -0400 Subject: [PATCH 137/154] fix: register OTel context manager so AI SDK spans thread into Effect traces (#22645) --- bun.lock | 2 ++ packages/opencode/package.json | 6 ++++-- packages/opencode/src/effect/observability.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index a6f9891dd1..aeab042cf3 100644 --- a/bun.lock +++ b/bun.lock @@ -359,6 +359,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@openrouter/ai-sdk-provider": "2.5.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9ddf1fa9f6..59be93d620 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -113,13 +113,15 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/plugin": "workspace:*", - "@opencode-ai/server": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@openrouter/ai-sdk-provider": "2.5.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@openrouter/ai-sdk-provider": "2.5.1", "@opentui/core": "0.1.99", "@opentui/solid": "0.1.99", "@parcel/watcher": "2.5.1", diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index 1e4863f924..f79306bf1e 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -46,6 +46,18 @@ export namespace Observability { const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") const SdkBase = await import("@opentelemetry/sdk-trace-base") + // @effect/opentelemetry creates a NodeTracerProvider but never calls + // register(), so the global @opentelemetry/api context manager stays + // as the no-op default. Non-Effect code (like the AI SDK) that calls + // tracer.startActiveSpan() relies on context.active() to find the + // parent span — without a real context manager every span starts a + // new trace. Registering AsyncLocalStorageContextManager fixes this. + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") + const { context } = await import("@opentelemetry/api") + const mgr = new AsyncLocalStorageContextManager() + mgr.enable() + context.setGlobalContextManager(mgr) + return NodeSdk.layer(() => ({ resource, spanProcessor: new SdkBase.BatchSpanProcessor( From 8ba4799b3ee67e681ada264702d235522520c570 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 15 Apr 2026 17:38:21 +0000 Subject: [PATCH 138/154] 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 f860e3774e..a12e6f7e5e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-3kpnjBg7AQanyDGTOFdYBFvo9O9Rfnu0Wmi8bY5LpEI=", - "aarch64-linux": "sha256-8rQ+SNUiSpA2Ea3NrYNGopHQsnY7Y8qBsXCqL6GMt24=", - "aarch64-darwin": "sha256-OASMkW5hnXucV6lSmxrQo73lGSEKN4MQPNGNV0i7jdo=", - "x86_64-darwin": "sha256-CmHqXlm8wnLcwSSK0ghxAf+DVurEltMaxrUbWh9/ZGE=" + "x86_64-linux": "sha256-PvIx2g1J5QIUIzkz2ABaAM4K/k/+xlBPDUExoOJNNuo=", + "aarch64-linux": "sha256-YTAL+P13L5hgNJdDSiBED/UNa5zdTntnUUYDYL+Jdzo=", + "aarch64-darwin": "sha256-y2VCJifYAp+H0lpDcJ0QfKNMG00Q/usFElaUIpdc8Vs=", + "x86_64-darwin": "sha256-yz8edIlqLp06Y95ad8YjKz5azP7YATPle4TcDx6lM+U=" } } From 348a84969de64cc1623d8ddcf73336b449c5b1f5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:56:45 -0500 Subject: [PATCH 139/154] fix: ensure tool_use is always followed by tool_result (#22646) Co-authored-by: opencode-agent[bot] --- packages/opencode/src/provider/transform.ts | 27 +- .../opencode/test/provider/transform.test.ts | 104 +++++++ packages/opencode/test/session/llm.test.ts | 265 +++++++++++++++++- 3 files changed, 394 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bab056dae7..61561ec969 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -75,7 +75,7 @@ export namespace ProviderTransform { if (model.api.id.includes("claude")) { const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") - return msgs.map((msg) => { + msgs = msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { return { ...msg, @@ -101,6 +101,31 @@ export namespace ProviderTransform { return msg }) } + if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { + // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool + // content, e.g. [tool_use, tool_use, text], with: + // `tool_use` ids were found without `tool_result` blocks immediately after... + // + // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive + // assistant messages are later merged by the provider/SDK, so preserving the + // original [tool_use...] then [text] order still produces the invalid payload. + // + // The root cause appears to be somewhere upstream where the stream is originally + // processed. We were unable to locate an exact narrower reproduction elsewhere, + // so we keep this transform in place for the time being. + msgs = msgs.flatMap((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] + + const parts = msg.content + const first = parts.findIndex((part) => part.type === "tool-call") + if (first === -1) return [msg] + if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] + return [ + { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, + { ...msg, content: parts.filter((part) => part.type === "tool-call") }, + ] + }) + } if ( model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral") || diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 1b750d1b93..4952a126b3 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1271,6 +1271,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content).toBe("") expect(result[1].content).toHaveLength(1) }) + + test("splits anthropic assistant messages when text trails tool calls", () => { + const msgs = [ + { + role: "user", + content: [{ type: "text", text: "Check my home directory for PDFs" }], + }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + { type: "text", text: "I checked your home directory and looked for PDF files." }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } }, + { + type: "tool-result", + toolCallId: "toolu_2", + toolName: "glob", + output: { type: "text", value: "No files found" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(4) + expect(result[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], + }) + expect(result[2]).toMatchObject({ + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ], + }) + }) + + test("leaves valid anthropic assistant tool ordering unchanged", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "I checked your home directory and looked for PDF files." }, + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content).toMatchObject([ + { type: "text", text: "I checked your home directory and looked for PDF files." }, + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ]) + }) + + test("splits vertex anthropic assistant messages when text trails tool calls", () => { + const model = { + ...anthropicModel, + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-4@20250514", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + } + + const msgs = [ + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + { type: "text", text: "I checked your home directory and looked for PDF files." }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], + }) + expect(result[1]).toMatchObject({ + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ], + }) + }) }) describe("ProviderTransform.message - strip openai metadata when store=false", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index cbf767b4bd..a7fde90f01 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" -import type { MessageV2 } from "../../src/session/message-v2" +import { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -909,6 +909,269 @@ describe("session.llm.stream", () => { }) }) + test("sends anthropic tool_use blocks with tool_result immediately after them", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("anthropic", "claude-opus-4-6") + const model = source.model + const chunks = [ + { + type: "message_start", + message: { + id: "msg-tool-order", + model: model.id, + usage: { + input_tokens: 3, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "ok" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { + input_tokens: 3, + output_tokens: 2, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic"], + provider: { + anthropic: { + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { + [model.id]: model, + }, + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-anthropic-tools") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("user-anthropic-tools"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" }, + } satisfies MessageV2.User + + const input = [ + { + info: { + id: "msg_user", + sessionID, + role: "user", + time: { created: 1 }, + agent: "gentleman", + model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" }, + }, + parts: [ + { + id: "p_user", + sessionID, + messageID: "msg_user", + type: "text", + text: "Can you check whether there are any PDF files in my home directory?", + }, + ], + }, + { + info: { + id: "msg_call", + sessionID, + parentID: "msg_user", + role: "assistant", + mode: "gentleman", + agent: "gentleman", + variant: "max", + path: { cwd: "/root", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "claude-opus-4-6", + providerID: "anthropic", + time: { created: 2, completed: 3 }, + finish: "tool-calls", + }, + parts: [ + { + id: "p_step", + sessionID, + messageID: "msg_call", + type: "step-start", + }, + { + id: "p_read", + sessionID, + messageID: "msg_call", + type: "tool", + tool: "read", + callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT", + state: { + status: "completed", + input: { filePath: "/root" }, + output: "/root", + metadata: {}, + title: "root", + time: { start: 10, end: 11 }, + }, + }, + { + id: "p_glob", + sessionID, + messageID: "msg_call", + type: "tool", + tool: "glob", + callID: "toolu_01APxrADs7VozN8uWzw9WwHr", + state: { + status: "completed", + input: { pattern: "**/*.pdf", path: "/root" }, + output: "No files found", + metadata: {}, + title: "root", + time: { start: 12, end: 13 }, + }, + }, + { + id: "p_text", + sessionID, + messageID: "msg_call", + type: "text", + text: "I checked your home directory and looked for PDF files.", + time: { start: 14, end: 15 }, + }, + ], + }, + ] as any[] + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: [], + messages: await MessageV2.toModelMessages(input as any, resolved), + tools: { + read: tool({ + description: "Stub read tool", + inputSchema: z.object({ + filePath: z.string(), + }), + execute: async () => ({ output: "stub" }), + }), + glob: tool({ + description: "Stub glob tool", + inputSchema: z.object({ + pattern: z.string(), + path: z.string().optional(), + }), + execute: async () => ({ output: "stub" }), + }), + }, + }) + + const capture = await request + const body = capture.body + + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + expect(body.messages).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I checked your home directory and looked for PDF files.", + }, + { + type: "tool_use", + id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT", + name: "read", + input: { filePath: "/root" }, + }, + { + type: "tool_use", + id: "toolu_01APxrADs7VozN8uWzw9WwHr", + name: "glob", + input: { pattern: "**/*.pdf", path: "/root" }, + cache_control: { + type: "ephemeral", + }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT", + content: "/root", + }, + { + type: "tool_result", + tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr", + content: "No files found", + cache_control: { + type: "ephemeral", + }, + }, + ], + }, + ]) + }, + }) + }) + test("sends Google API payload for Gemini models", async () => { const server = state.server if (!server) { From e83b22159d0c6b393acf9b04fdd6798397bb782d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:50:33 -0500 Subject: [PATCH 140/154] tweak: ensure auto continuing compaction is tracked as agent initiated for github copilot (#22567) Co-authored-by: opencode-agent[bot] --- packages/opencode/src/plugin/github-copilot/copilot.ts | 10 +++++++++- packages/opencode/src/session/compaction.ts | 4 ++++ packages/opencode/test/session/compaction.test.ts | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index ac685f74da..e12d182e4f 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -355,7 +355,15 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { }) .catch(() => undefined) - if (parts?.data.parts?.some((part) => part.type === "compaction")) { + if ( + parts?.data.parts?.some( + (part) => + part.type === "compaction" || + // Auto-compaction resumes via a synthetic user text part. Treat only + // that marked followup as agent-initiated so manual prompts stay user-initiated. + (part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true), + ) + ) { output.headers["x-initiator"] = "agent" return } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index de0f8d0788..4978ef5478 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -345,6 +345,10 @@ When constructing the summary, try to stick to this template: messageID: continueMsg.id, sessionID: input.sessionID, type: "text", + // Internal marker for auto-compaction followups so provider plugins + // can distinguish them from manual post-compaction user prompts. + // This is not a stable plugin contract and may change or disappear. + metadata: { compaction_continue: true }, synthetic: true, text, time: { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index ddfe859113..251447762d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -843,6 +843,7 @@ describe("session.compaction.process", () => { expect(last?.parts[0]).toMatchObject({ type: "text", synthetic: true, + metadata: { compaction_continue: true }, }) if (last?.parts[0]?.type === "text") { expect(last.parts[0].text).toContain("Continue if you have next steps") From 250e30bc7da7afa1c83578108e9af23210dda87a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 17:28:01 -0400 Subject: [PATCH 141/154] add experimental permission HttpApi slice (#22385) --- packages/opencode/specs/effect/http-api.md | 77 ++++------ packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/control-plane/schema.ts | 7 +- packages/opencode/src/permission/index.ts | 140 ++++++++++-------- packages/opencode/src/permission/schema.ts | 6 +- packages/opencode/src/pty/schema.ts | 3 +- packages/opencode/src/question/schema.ts | 6 +- .../src/server/instance/experimental.ts | 2 - .../src/server/instance/httpapi/index.ts | 7 - .../src/server/instance/httpapi/permission.ts | 72 +++++++++ .../src/server/instance/httpapi/question.ts | 101 ++++++++----- .../src/server/instance/httpapi/server.ts | 135 +++++++++++++++++ .../src/server/instance/permission.ts | 4 +- .../opencode/src/server/instance/session.ts | 4 +- packages/opencode/src/session/index.ts | 4 +- packages/opencode/src/session/schema.ts | 7 +- packages/opencode/src/sync/schema.ts | 3 +- packages/opencode/src/tool/schema.ts | 3 +- packages/opencode/src/util/effect-zod.ts | 16 ++ .../test/server/question-httpapi.test.ts | 78 ---------- .../opencode/test/util/effect-zod.test.ts | 130 +++++++++++++++- 21 files changed, 553 insertions(+), 254 deletions(-) delete mode 100644 packages/opencode/src/server/instance/httpapi/index.ts create mode 100644 packages/opencode/src/server/instance/httpapi/permission.ts create mode 100644 packages/opencode/src/server/instance/httpapi/server.ts delete mode 100644 packages/opencode/test/server/question-httpapi.test.ts diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index cce3f4081f..1794927cce 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -121,14 +121,13 @@ Why `question` first: Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers. -### 4. Run in parallel before replacing +### 4. Build in parallel, do not bridge into Hono -Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare: +The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`. -- handler ergonomics -- OpenAPI output -- auth and middleware integration -- test ergonomics +The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`. + +The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes. ### 5. Migrate JSON route groups gradually @@ -218,17 +217,15 @@ Placement rule: Suggested file layout for a repeatable spike: -- `src/server/instance/httpapi/question.ts` -- `src/server/instance/httpapi/index.ts` -- `test/server/question-httpapi.test.ts` -- `test/server/question-httpapi-openapi.test.ts` +- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group +- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups +- `test/server/question-httpapi.test.ts` — end-to-end test against the real service Suggested responsibilities: -- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice -- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer -- `question-httpapi.test.ts` proves the route works end-to-end against the real service -- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints +- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers +- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup) +- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server ## Example migration shape @@ -248,11 +245,12 @@ Each route-group spike should follow the same shape. - keep handler bodies thin - keep transport mapping at the HTTP boundary only -### 3. Mounting +### 3. Standalone server -- mount under an experimental prefix such as `/experimental/httpapi` -- keep existing Hono routes unchanged -- expose separate OpenAPI output for the experimental slice first +- the Effect HTTP server is self-contained in `httpapi/server.ts` +- it is **not** mounted into the Hono app — no bridge, no `toWebHandler` +- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover +- each route group exposes its own OpenAPI doc endpoint ### 4. Verification @@ -263,53 +261,32 @@ Each route-group spike should follow the same shape. ## Boundary composition -The first slices should keep the existing outer server composition and only replace the route contract and handler layer. +The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server. ### Auth -- keep `AuthMiddleware` at the outer Hono app level -- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices -- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler - -Practical rule: - -- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack +- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic` +- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served +- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer ### Instance and workspace lookup -- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context -- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler -- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them - -Practical rule: - -- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided -- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself +- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params +- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware` +- `HttpApi` handlers yield services from context and assume the correct instance has already been provided ### Error mapping - keep domain and service errors typed in the service layer - declare typed transport errors on the endpoint only when the route can actually return them intentionally -- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior - -Practical rule: - -- request decoding failures should remain transport-level `400`s +- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically - storage or lookup failures that are part of the route contract should be declared as typed endpoint errors -- unexpected defects can still fall through to the outer error middleware while the slice is experimental - -For the current parallel slices, this means: - -- auth still composes outside `HttpApi` -- instance selection still composes outside `HttpApi` -- success payloads should be schema-defined from canonical Effect schemas -- known route errors should be modeled at the endpoint boundary incrementally instead of all at once ## Exit criteria for the spike The first slice is successful if: -- the endpoints run in parallel with the current Hono routes +- the standalone Effect server starts and serves the endpoints independently of the Hono server - the handlers reuse the existing Effect service - request decoding and response shapes are schema-defined from canonical Effect schemas - any remaining Zod boundary usage is derived from `.zod` or clearly temporary @@ -324,8 +301,8 @@ The first parallel `question` spike gave us a concrete pattern to reuse. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. - if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged. -- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix. +- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged. +- compare generated OpenAPI semantically at the route and schema level. ## Route inventory diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ce49218b71..8857696b05 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -35,7 +35,7 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: Permission.Ruleset, + permission: Permission.Ruleset.zod, model: z .object({ modelID: ModelID.zod, diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 7262a380b0..4c7ced010d 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,10 +1,13 @@ import { Schema } from "effect" import z from "zod" -import { withStatics } from "@/util/schema" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID")) +const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( + Schema.brand("WorkspaceID"), +) export type WorkspaceID = typeof workspaceIdSchema.Type diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index dc22d32b4b..b6a44e2582 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,75 +7,84 @@ import { Instance } from "@/project/instance" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" +import { zod } from "@/util/effect-zod" import { Log } from "@/util/log" +import { withStatics } from "@/util/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } 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 Action = Schema.Literals(["allow", "deny", "ask"]) + .annotate({ identifier: "PermissionAction" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Action = Schema.Schema.Type - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + export class Rule extends Schema.Class("PermissionRule")({ + permission: Schema.String, + pattern: Schema.String, + action: Action, + }) { + static readonly zod = zod(this) + } - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Ruleset = Schema.mutable(Schema.Array(Rule)) + .annotate({ identifier: "PermissionRuleset" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Ruleset = Schema.Schema.Type - 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 class Request extends Schema.Class("PermissionRequest")({ + id: PermissionID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional( + Schema.Struct({ + messageID: MessageID, + callID: Schema.String, + }), + ), + }) { + static readonly zod = zod(this) + } - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Reply = Schema.Schema.Type - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) + const reply = { + reply: Reply, + message: Schema.optional(Schema.String), + } + + export const ReplyBody = Schema.Struct(reply) + .annotate({ identifier: "PermissionReplyBody" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type ReplyBody = Schema.Schema.Type + + export class Approval extends Schema.Class("PermissionApproval")({ + projectID: ProjectID, + patterns: Schema.Array(Schema.String), + }) { + static readonly zod = zod(this) + } export const Event = { - Asked: BusEvent.define("permission.asked", Request), + Asked: BusEvent.define("permission.asked", Request.zod), Replied: BusEvent.define( "permission.replied", - z.object({ - sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), + zod( + Schema.Struct({ + sessionID: SessionID, + requestID: PermissionID, + reply: Reply, + }), + ), ), } @@ -103,20 +112,27 @@ export namespace Permission { export type Error = DeniedError | RejectedError | CorrectedError - export const AskInput = Request.partial({ id: true }).extend({ + export const AskInput = Schema.Struct({ + ...Request.fields, + id: Schema.optional(PermissionID), ruleset: Ruleset, }) + .annotate({ identifier: "PermissionAskInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type AskInput = Schema.Schema.Type - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), + export const ReplyInput = Schema.Struct({ + requestID: PermissionID, + ...reply, }) + .annotate({ identifier: "PermissionReplyInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type ReplyInput = Schema.Schema.Type export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect + readonly ask: (input: AskInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly list: () => Effect.Effect> } interface PendingEntry { @@ -163,7 +179,7 @@ export namespace Permission { }), ) - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false @@ -183,10 +199,10 @@ export namespace Permission { if (!needsAsk) return const id = request.id ?? PermissionID.ascending() - const info: Request = { + const info = Schema.decodeUnknownSync(Request)({ id, ...request, - } + }) log.info("asking", { id, permission: info.permission, patterns: info.patterns }) const deferred = yield* Deferred.make() @@ -200,7 +216,7 @@ export namespace Permission { ) }) - const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { + const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { const { approved, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) if (!existing) return diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 2f1190a238..6ac9389a58 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -2,9 +2,13 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" -export class PermissionID extends Newtype()("PermissionID", Schema.String) { +export class PermissionID extends Newtype()( + "PermissionID", + Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }), +) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) } diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index deb498891a..0758fe8206 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index e5a0496c96..41186161d0 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -2,9 +2,13 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" -export class QuestionID extends Newtype()("QuestionID", Schema.String) { +export class QuestionID extends Newtype()( + "QuestionID", + Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }), +) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 6309a21bb9..e8e46b2e3b 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -18,7 +18,6 @@ import { lazy } from "../../util/lazy" import { Effect, Option } from "effect" import { WorkspaceRoutes } from "./workspace" import { Agent } from "@/agent/agent" -import { HttpApiRoutes } from "./httpapi" const ConsoleOrgOption = z.object({ accountID: z.string(), @@ -40,7 +39,6 @@ const ConsoleSwitchBody = z.object({ export const ExperimentalRoutes = lazy(() => new Hono() - .route("/httpapi", HttpApiRoutes()) .get( "/console", describeRoute({ diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts deleted file mode 100644 index 523041de84..0000000000 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { lazy } from "@/util/lazy" -import { Hono } from "hono" -import { QuestionHttpApiHandler } from "./question" - -export const HttpApiRoutes = lazy(() => - new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler), -) diff --git a/packages/opencode/src/server/instance/httpapi/permission.ts b/packages/opencode/src/server/instance/httpapi/permission.ts new file mode 100644 index 0000000000..e3d152c5a4 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/permission.ts @@ -0,0 +1,72 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/httpapi/permission" + +export const PermissionApi = HttpApi.make("permission") + .add( + HttpApiGroup.make("permission") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Permission.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.list", + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: PermissionID }, + payload: Permission.ReplyBody, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.reply", + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "permission", + description: "Experimental HttpApi permission routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const PermissionLive = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => + handlers.handle("list", list).handle("reply", reply), + ) + }), +).pipe(Layer.provide(Permission.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index ef0f41734b..686c6abb17 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/packages/opencode/src/server/instance/httpapi/question.ts @@ -1,44 +1,71 @@ -import { AppLayer } from "@/effect/app-runtime" -import { memoMap } from "@/effect/run-service" import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { lazy } from "@/util/lazy" -import { makeQuestionHandler, questionApi } from "@opencode-ai/server" -import { Effect, Layer } from "effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import type { Handler } from "hono" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const root = "/experimental/httpapi/question" -const QuestionLive = makeQuestionHandler({ - list: Effect.fn("QuestionHttpApi.host.list")(function* () { - const svc = yield* Question.Service - return yield* svc.list() - }), - reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) { - const svc = yield* Question.Service - yield* svc.reply({ - requestID: QuestionID.make(input.requestID), - answers: input.answers, - }) - }), -}).pipe(Layer.provide(Question.defaultLayer)) - -const web = lazy(() => - HttpRouter.toWebHandler( - Layer.mergeAll( - AppLayer, - HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe( - Layer.provide(QuestionLive), - Layer.provide(HttpServer.layerServices), +export const QuestionApi = HttpApi.make("question") + .add( + HttpApiGroup.make("question") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Question.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.list", + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: QuestionID }, + payload: Question.Reply, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reply", + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "question", + description: "Experimental HttpApi question routes.", + }), ), - ), - { - disableLogger: true, - memoMap, - }, - ), -) + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) -export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw) +export const QuestionLive = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + return HttpApiBuilder.group(QuestionApi, "question", (handlers) => + handlers.handle("list", list).handle("reply", reply), + ) + }), +).pipe(Layer.provide(Question.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts new file mode 100644 index 0000000000..363e93a240 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -0,0 +1,135 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer, Redacted, Schema } from "effect" +import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createServer } from "node:http" +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { Flag } from "@/flag/flag" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import { Permission } from "@/permission" +import { Question } from "@/question" +import { PermissionApi, PermissionLive } from "./permission" +import { QuestionApi, QuestionLive } from "./question" + +const Query = Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + auth_token: Schema.optional(Schema.String), +}) + +const Headers = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-opencode-directory": Schema.optional(Schema.String), +}) + +export namespace ExperimentalHttpApiServer { + function text(input: string, status: number, headers?: Record) { + return HttpServerResponse.text(input, { status, headers }) + } + + function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } + } + + class Unauthorized extends Schema.TaggedErrorClass()( + "Unauthorized", + { message: Schema.String }, + { httpApiStatus: 401 }, + ) {} + + class Authorization extends HttpApiMiddleware.Service()("@opencode/ExperimentalHttpApiAuthorization", { + error: Unauthorized, + security: { + basic: HttpApiSecurity.basic, + }, + }) {} + + const normalize = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + if (!query.auth_token) return yield* effect + const req = yield* HttpServerRequest.HttpServerRequest + const next = req.modify({ + headers: { + ...req.headers, + authorization: `Basic ${query.auth_token}`, + }, + }) + return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next)) + }) + }), + ).layer + + const auth = Layer.succeed( + Authorization, + Authorization.of({ + basic: (effect, { credential }) => + Effect.gen(function* () { + if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect + + const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + if (credential.username !== user) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + return yield* effect + }), + }), + ) + + const instance = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + const headers = yield* HttpServerRequest.schemaHeaders(Headers) + const raw = query.directory || headers["x-opencode-directory"] || process.cwd() + const workspace = query.workspace || undefined + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(raw)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect + return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) + }) + }), + ).layer + + const QuestionSecured = QuestionApi.middleware(Authorization) + const PermissionSecured = PermissionApi.middleware(Authorization) + + export const routes = Layer.mergeAll( + HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( + Layer.provide(QuestionLive), + ), + HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( + Layer.provide(PermissionLive), + ), + ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) + + export const layer = (opts: { hostname: string; port: number }) => + HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })), + ) + + export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(Question.defaultLayer), + Layer.provideMerge(Permission.defaultLayer), + ) +} diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts index 3f93709354..b8c2244140 100644 --- a/packages/opencode/src/server/instance/permission.ts +++ b/packages/opencode/src/server/instance/permission.ts @@ -33,7 +33,7 @@ export const PermissionRoutes = lazy(() => requestID: PermissionID.zod, }), ), - validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), + validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") @@ -60,7 +60,7 @@ export const PermissionRoutes = lazy(() => description: "List of pending permissions", content: { "application/json": { - schema: resolver(Permission.Request.array()), + schema: resolver(Permission.Request.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index a011c32f9b..4f02e35fac 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -274,7 +274,7 @@ export const SessionRoutes = lazy(() => "json", z.object({ title: z.string().optional(), - permission: Permission.Ruleset.optional(), + permission: Permission.Ruleset.zod.optional(), time: z .object({ archived: z.number().optional(), @@ -1093,7 +1093,7 @@ export const SessionRoutes = lazy(() => permissionID: PermissionID.zod, }), ), - validator("json", z.object({ response: Permission.Reply })), + validator("json", z.object({ response: Permission.Reply.zod })), async (c) => { const params = c.req.valid("param") await AppRuntime.runPromise( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d8ab812349..49d8359497 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -144,7 +144,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), - permission: Permission.Ruleset.optional(), + permission: Permission.Ruleset.zod.optional(), revert: z .object({ messageID: MessageID.zod, @@ -193,7 +193,7 @@ export namespace Session { export const RemoveInput = SessionID.zod export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) - export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }) + export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }) export const SetRevertInput = z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 856ab31142..efed280c98 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export const SessionID = Schema.String.pipe( +export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), @@ -14,7 +15,7 @@ export const SessionID = Schema.String.pipe( export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.pipe( +export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), @@ -24,7 +25,7 @@ export const MessageID = Schema.String.pipe( export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.pipe( +export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 5cec8b1f7a..37cdbd718f 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export const EventID = Schema.String.pipe( +export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 823bb0aede..ac41fd1606 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 97cbbd2fc9..553d7a0650 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,11 +1,21 @@ import { Schema, SchemaAST } from "effect" import z from "zod" +/** + * Annotation key for providing a hand-crafted Zod schema that the walker + * should use instead of re-deriving from the AST. Attach it via + * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. + */ +export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined + if (override) return override + const out = body(ast) const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) @@ -57,6 +67,12 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny { } function union(ast: SchemaAST.Union): z.ZodTypeAny { + // When every member is a string literal, emit z.enum() so that + // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. + if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { + return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) + } + const items = ast.types.map(walk) if (items.length === 1) return items[0] if (items.length < 2) return fail(ast) diff --git a/packages/opencode/test/server/question-httpapi.test.ts b/packages/opencode/test/server/question-httpapi.test.ts deleted file mode 100644 index 00cc32f59e..0000000000 --- a/packages/opencode/test/server/question-httpapi.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { AppRuntime } from "../../src/effect/app-runtime" -import { Instance } from "../../src/project/instance" -import { Question } from "../../src/question" -import { Server } from "../../src/server/server" -import { SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) - -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) - -afterEach(async () => { - await Instance.disposeAll() -}) - -describe("experimental question httpapi", () => { - test("lists pending questions, replies, and serves docs", async () => { - await using tmp = await tmpdir({ git: true }) - const app = Server.Default().app - const headers = { - "content-type": "application/json", - "x-opencode-directory": tmp.path, - } - const questions: ReadonlyArray = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - let pending!: ReturnType - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - pending = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) - }, - }) - - const list = await app.request("/experimental/httpapi/question", { - headers, - }) - - expect(list.status).toBe(200) - const items = await list.json() - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ questions }) - - const doc = await app.request("/experimental/httpapi/question/doc", { - headers, - }) - - expect(doc.status).toBe(200) - const spec = await doc.json() - expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list") - expect(spec.paths["/experimental/httpapi/question/{requestID}/reply"]?.post?.operationId).toBe("question.reply") - - const reply = await app.request(`/experimental/httpapi/question/${items[0].id}/reply`, { - method: "POST", - headers, - body: JSON.stringify({ answers: [["Option 1"]] }), - }) - - expect(reply.status).toBe(200) - expect(await reply.json()).toBe(true) - expect(await pending).toEqual([["Option 1"]]) - }) -}) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 4004ca2d23..7f7249514d 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" +import z from "zod" -import { zod } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "../../src/util/effect-zod" + +function json(schema: z.ZodTypeAny) { + const { $schema: _, ...rest } = z.toJSONSchema(schema) + return rest +} describe("util.effect-zod", () => { test("converts class schemas for route dto shapes", () => { @@ -58,4 +64,126 @@ describe("util.effect-zod", () => { test("throws for unsupported tuple schemas", () => { expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") }) + + test("string literal unions produce z.enum with enum in JSON Schema", () => { + const Action = Schema.Literals(["allow", "deny", "ask"]) + const out = zod(Action) + + expect(out.parse("allow")).toBe("allow") + expect(out.parse("deny")).toBe("deny") + expect(() => out.parse("nope")).toThrow() + + // Matches native z.enum JSON Schema output + const bridged = json(out) + const native = json(z.enum(["allow", "deny", "ask"])) + expect(bridged).toEqual(native) + expect(bridged.enum).toEqual(["allow", "deny", "ask"]) + }) + + test("ZodOverride annotation provides the Zod schema for branded IDs", () => { + const override = z.string().startsWith("per") + const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID")) + + const Parent = Schema.Struct({ id: ID, name: Schema.String }) + const out = zod(Parent) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" }) + + const schema = json(out) as any + expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + }) + + test("Schema.Class nested in a parent preserves ref via identifier", () => { + class Inner extends Schema.Class("MyInner")({ + value: Schema.String, + }) {} + + class Outer extends Schema.Class("MyOuter")({ + inner: Inner, + }) {} + + const out = zod(Outer) + expect(out.meta()?.ref).toBe("MyOuter") + + const shape = (out as any).shape ?? (out as any)._def?.shape?.() + expect(shape.inner.meta()?.ref).toBe("MyInner") + }) + + test("Schema.Class preserves identifier and uses enum format", () => { + class Rule extends Schema.Class("PermissionRule")({ + permission: Schema.String, + pattern: Schema.String, + action: Schema.Literals(["allow", "deny", "ask"]), + }) {} + + const out = zod(Rule) + expect(out.meta()?.ref).toBe("PermissionRule") + + const schema = json(out) as any + expect(schema.properties.action).toEqual({ + type: "string", + enum: ["allow", "deny", "ask"], + }) + }) + + test("ZodOverride on ID carries pattern through Schema.Class", () => { + const ID = Schema.String.annotate({ + [ZodOverride]: z.string().startsWith("per"), + }) + + class Request extends Schema.Class("TestRequest")({ + id: ID, + name: Schema.String, + }) {} + + const schema = json(zod(Request)) as any + expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + expect(schema.properties.name).toEqual({ type: "string" }) + }) + + test("Permission schemas match original Zod equivalents", () => { + const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") }) + const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") }) + const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") }) + + class Tool extends Schema.Class("PermissionTool")({ + messageID: MsgID, + callID: Schema.String, + }) {} + + class Request extends Schema.Class("PermissionRequest")({ + id: PerID, + sessionID: SesID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Tool), + }) {} + + const bridged = json(zod(Request)) as any + expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) + expect(bridged.properties.permission).toEqual({ type: "string" }) + expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort()) + + // Tool field is present with the ref from Schema.Class identifier + const toolSchema = json(zod(Tool)) as any + expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" }) + expect(toolSchema.properties.callID).toEqual({ type: "string" }) + }) + + test("ZodOverride survives Schema.brand", () => { + const override = z.string().startsWith("ses") + const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID")) + + // The branded schema's AST still has the override + class Parent extends Schema.Class("Parent")({ + sessionID: ID, + }) {} + + const schema = json(zod(Parent)) as any + expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) + }) }) From 3b75f16119b914fa6eb7dd451a0f3fb20d22d69f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 15 Apr 2026 21:29:10 +0000 Subject: [PATCH 142/154] chore: generate --- packages/sdk/openapi.json | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ee3538d55f..c59e1ab910 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8143,7 +8143,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg.*" }, "callID": { "type": "string" @@ -8155,10 +8156,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^que.*" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "questions": { "description": "Questions to ask", @@ -8196,10 +8199,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que.*" }, "answers": { "type": "array", @@ -8227,10 +8232,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que.*" } }, "required": ["sessionID", "requestID"] From 6bed7d469d8f6a18d5543cc668d951d0d1e09776 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 17:32:56 -0400 Subject: [PATCH 143/154] feat(opencode): improve telemetry tracing and request spans (#22653) --- packages/opencode/src/effect/app-runtime.ts | 3 +- packages/opencode/src/effect/run-service.ts | 2 +- .../opencode/src/server/instance/config.ts | 34 ++++++++----------- .../opencode/src/server/instance/session.ts | 28 +++++++++------ .../opencode/src/server/instance/trace.ts | 33 ++++++++++++++++++ 5 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 packages/opencode/src/server/instance/trace.ts diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 5948bd25e6..257922dafe 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -49,7 +49,6 @@ import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" export const AppLayer = Layer.mergeAll( - Observability.layer, AppFileSystem.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, @@ -95,7 +94,7 @@ export const AppLayer = Layer.mergeAll( Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, -) +).pipe(Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 13104c88b3..3de82e0d11 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -38,7 +38,7 @@ export function attach(effect: Effect.Effect): Effect.Effect(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined - const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap })) + const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer), { memoMap })) return { runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(attach(service.use(fn))), diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 41d5872c98..aa770726df 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -5,12 +5,10 @@ import { Config } from "../../config/config" import { Provider } from "../../provider/provider" import { mapValues } from "remeda" import { errors } from "../error" -import { Log } from "../../util/log" import { lazy } from "../../util/lazy" import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" - -const log = Log.create({ service: "server" }) +import { jsonRequest } from "./trace" export const ConfigRoutes = lazy(() => new Hono() @@ -31,9 +29,11 @@ export const ConfigRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))) - }, + async (c) => + jsonRequest("ConfigRoutes.get", c, function* () { + const cfg = yield* Config.Service + return yield* cfg.get() + }), ) .patch( "/", @@ -82,18 +82,14 @@ export const ConfigRoutes = lazy(() => }, }, }), - async (c) => { - using _ = log.time("providers") - const providers = await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - return mapValues(yield* svc.list(), (item) => item) - }), - ) - return c.json({ - providers: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - }) - }, + async (c) => + jsonRequest("ConfigRoutes.providers", c, function* () { + const svc = yield* Provider.Service + const providers = mapValues(yield* svc.list(), (item) => item) + return { + providers: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + } + }), ), ) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 4f02e35fac..0bce3085e0 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -26,6 +26,7 @@ import { errors } from "../error" import { lazy } from "../../util/lazy" import { Bus } from "../../bus" import { NamedError } from "@opencode-ai/shared/util/error" +import { jsonRequest } from "./trace" const log = Log.create({ service: "server" }) @@ -94,10 +95,11 @@ export const SessionRoutes = lazy(() => ...errors(400), }, }), - async (c) => { - const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) - return c.json(Object.fromEntries(result)) - }, + async (c) => + jsonRequest("SessionRoutes.status", c, function* () { + const svc = yield* SessionStatus.Service + return Object.fromEntries(yield* svc.list()) + }), ) .get( "/:sessionID", @@ -126,8 +128,10 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) - return c.json(session) + return jsonRequest("SessionRoutes.get", c, function* () { + const session = yield* Session.Service + return yield* session.get(sessionID) + }) }, ) .get( @@ -157,8 +161,10 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) - return c.json(session) + return jsonRequest("SessionRoutes.children", c, function* () { + const session = yield* Session.Service + return yield* session.children(sessionID) + }) }, ) .get( @@ -187,8 +193,10 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID))) - return c.json(todos) + return jsonRequest("SessionRoutes.todo", c, function* () { + const todo = yield* Todo.Service + return yield* todo.get(sessionID) + }) }, ) .post( diff --git a/packages/opencode/src/server/instance/trace.ts b/packages/opencode/src/server/instance/trace.ts new file mode 100644 index 0000000000..b3adbb4c80 --- /dev/null +++ b/packages/opencode/src/server/instance/trace.ts @@ -0,0 +1,33 @@ +import type { Context } from "hono" +import { Effect } from "effect" +import { AppRuntime } from "../../effect/app-runtime" + +type AppEnv = Parameters[0] extends Effect.Effect ? R : never + +export function runRequest(name: string, c: Context, effect: Effect.Effect) { + const url = new URL(c.req.url) + return AppRuntime.runPromise( + effect.pipe( + Effect.withSpan(name, { + attributes: { + "http.method": c.req.method, + "http.path": url.pathname, + }, + }), + ), + ) +} + +export async function jsonRequest( + name: string, + c: C, + effect: (c: C) => Effect.gen.Return, +) { + return c.json( + await runRequest( + name, + c, + Effect.gen(() => effect(c)), + ), + ) +} From d2ea6700aa2e6bdf5d04fe70ba893afbb320adbd Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:44:53 -0400 Subject: [PATCH 144/154] fix(core): Remove dead code and documentation related to the obsolete list tool. (#22672) --- packages/opencode/src/acp/agent.ts | 3 - packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 10 -- .../src/cli/cmd/tui/routes/session/index.tsx | 18 --- packages/opencode/src/tool/ls.ts | 122 ------------------ packages/opencode/src/tool/ls.txt | 1 - packages/web/src/content/docs/ar/modes.mdx | 1 - .../web/src/content/docs/ar/permissions.mdx | 3 +- packages/web/src/content/docs/ar/tools.mdx | 18 +-- packages/web/src/content/docs/bs/modes.mdx | 1 - .../web/src/content/docs/bs/permissions.mdx | 3 +- packages/web/src/content/docs/bs/tools.mdx | 18 +-- packages/web/src/content/docs/da/modes.mdx | 1 - .../web/src/content/docs/da/permissions.mdx | 3 +- packages/web/src/content/docs/da/tools.mdx | 18 +-- packages/web/src/content/docs/de/modes.mdx | 1 - .../web/src/content/docs/de/permissions.mdx | 3 +- packages/web/src/content/docs/de/tools.mdx | 18 +-- packages/web/src/content/docs/es/modes.mdx | 1 - .../web/src/content/docs/es/permissions.mdx | 3 +- packages/web/src/content/docs/es/tools.mdx | 18 +-- packages/web/src/content/docs/fr/modes.mdx | 1 - .../web/src/content/docs/fr/permissions.mdx | 3 +- packages/web/src/content/docs/fr/tools.mdx | 18 +-- packages/web/src/content/docs/it/modes.mdx | 1 - .../web/src/content/docs/it/permissions.mdx | 3 +- packages/web/src/content/docs/it/tools.mdx | 18 +-- packages/web/src/content/docs/ja/modes.mdx | 1 - .../web/src/content/docs/ja/permissions.mdx | 3 +- packages/web/src/content/docs/ja/tools.mdx | 18 +-- packages/web/src/content/docs/ko/modes.mdx | 1 - .../web/src/content/docs/ko/permissions.mdx | 3 +- packages/web/src/content/docs/ko/tools.mdx | 18 +-- packages/web/src/content/docs/modes.mdx | 1 - packages/web/src/content/docs/nb/modes.mdx | 1 - .../web/src/content/docs/nb/permissions.mdx | 3 +- packages/web/src/content/docs/nb/tools.mdx | 18 +-- packages/web/src/content/docs/permissions.mdx | 3 +- packages/web/src/content/docs/pl/modes.mdx | 1 - .../web/src/content/docs/pl/permissions.mdx | 3 +- packages/web/src/content/docs/pl/tools.mdx | 18 +-- packages/web/src/content/docs/pt-br/modes.mdx | 1 - .../src/content/docs/pt-br/permissions.mdx | 3 +- packages/web/src/content/docs/pt-br/tools.mdx | 18 +-- packages/web/src/content/docs/ru/modes.mdx | 1 - .../web/src/content/docs/ru/permissions.mdx | 3 +- packages/web/src/content/docs/ru/tools.mdx | 18 +-- packages/web/src/content/docs/th/modes.mdx | 1 - .../web/src/content/docs/th/permissions.mdx | 3 +- packages/web/src/content/docs/th/tools.mdx | 18 +-- packages/web/src/content/docs/tools.mdx | 19 +-- packages/web/src/content/docs/tr/modes.mdx | 1 - .../web/src/content/docs/tr/permissions.mdx | 3 +- packages/web/src/content/docs/tr/tools.mdx | 18 +-- packages/web/src/content/docs/zh-cn/modes.mdx | 1 - .../src/content/docs/zh-cn/permissions.mdx | 3 +- packages/web/src/content/docs/zh-cn/tools.mdx | 18 +-- packages/web/src/content/docs/zh-tw/modes.mdx | 1 - .../src/content/docs/zh-tw/permissions.mdx | 3 +- packages/web/src/content/docs/zh-tw/tools.mdx | 18 +-- 60 files changed, 37 insertions(+), 516 deletions(-) delete mode 100644 packages/opencode/src/tool/ls.ts delete mode 100644 packages/opencode/src/tool/ls.txt diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8ac09e4bb3..5cbf4ed1f9 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1566,7 +1566,6 @@ export namespace ACP { case "context7_get_library_docs": return "search" - case "list": case "read": return "read" @@ -1587,8 +1586,6 @@ export namespace ACP { return input["path"] ? [{ path: input["path"] }] : [] case "bash": return [] - case "list": - return input["path"] ? [{ path: input["path"] }] : [] default: return [] } diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 60f52e403b..b001389461 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -15,7 +15,7 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 17fc4bc087..2d3574c683 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -15,7 +15,6 @@ import { Permission } from "../../permission" import { Tool } from "../../tool/tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" -import { ListTool } from "../../tool/ls" import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" @@ -103,14 +102,6 @@ function grep(info: ToolProps) { }) } -function list(info: ToolProps) { - const dir = info.input.path ? normalizePath(info.input.path) : "" - inline({ - icon: "→", - title: dir ? `List ${dir}` : "List", - }) -} - function read(info: ToolProps) { const file = normalizePath(info.input.filePath) const pairs = Object.entries(info.input).filter(([key, value]) => { @@ -420,7 +411,6 @@ export const RunCommand = cmd({ if (part.tool === "bash") return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) - if (part.tool === "list") return list(props(part)) if (part.tool === "read") return read(props(part)) if (part.tool === "write") return write(props(part)) if (part.tool === "webfetch") return webfetch(props(part)) 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 c7790006f4..2b95cd5ae4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -41,7 +41,6 @@ import { BashTool } from "@/tool/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" -import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" @@ -1555,9 +1554,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - - @@ -1936,20 +1932,6 @@ function Grep(props: ToolProps) { ) } -function List(props: ToolProps) { - const dir = createMemo(() => { - if (props.input.path) { - return normalizePath(props.input.path) - } - return "" - }) - return ( - - List {dir()} - - ) -} - function WebFetch(props: ToolProps) { return ( diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts deleted file mode 100644 index f3b044cbc1..0000000000 --- a/packages/opencode/src/tool/ls.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as path from "path" -import z from "zod" -import { Effect } from "effect" -import * as Stream from "effect/Stream" -import { InstanceState } from "@/effect/instance-state" -import { Ripgrep } from "../file/ripgrep" -import { assertExternalDirectoryEffect } from "./external-directory" -import DESCRIPTION from "./ls.txt" -import { Tool } from "./tool" - -export const IGNORE_PATTERNS = [ - "node_modules/", - "__pycache__/", - ".git/", - "dist/", - "build/", - "target/", - "vendor/", - "bin/", - "obj/", - ".idea/", - ".vscode/", - ".zig-cache/", - "zig-out", - ".coverage", - "coverage/", - "vendor/", - "tmp/", - "temp/", - ".cache/", - "cache/", - "logs/", - ".venv/", - "venv/", - "env/", -] - -const LIMIT = 100 - -export const ListTool = Tool.define( - "list", - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - - return { - description: DESCRIPTION, - parameters: z.object({ - path: z - .string() - .describe("The absolute path to the directory to list (must be absolute, not relative)") - .optional(), - ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), - }), - execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) => - Effect.gen(function* () { - const ins = yield* InstanceState.context - const search = path.resolve(ins.directory, params.path || ".") - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) - - yield* ctx.ask({ - permission: "list", - patterns: [search], - always: ["*"], - metadata: { - path: search, - }, - }) - - const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || []) - const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe( - Stream.take(LIMIT + 1), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - - const truncated = files.length > LIMIT - if (truncated) files.length = LIMIT - - const dirs = new Set() - const map = new Map() - for (const file of files) { - const dir = path.dirname(file) - const parts = dir === "." ? [] : dir.split("/") - for (let i = 0; i <= parts.length; i++) { - dirs.add(i === 0 ? "." : parts.slice(0, i).join("/")) - } - if (!map.has(dir)) map.set(dir, []) - map.get(dir)!.push(path.basename(file)) - } - - function render(dir: string, depth: number): string { - const indent = " ".repeat(depth) - let output = "" - if (depth > 0) output += `${indent}${path.basename(dir)}/\n` - - const child = " ".repeat(depth + 1) - const dirs2 = Array.from(dirs) - .filter((item) => path.dirname(item) === dir && item !== dir) - .sort() - for (const item of dirs2) { - output += render(item, depth + 1) - } - - const files = map.get(dir) || [] - for (const file of files.sort()) { - output += `${child}${file}\n` - } - return output - } - - return { - title: path.relative(ins.worktree, search), - metadata: { - count: files.length, - truncated, - }, - output: `${search}/\n` + render(".", 0), - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/ls.txt b/packages/opencode/src/tool/ls.txt deleted file mode 100644 index 543720d46b..0000000000 --- a/packages/opencode/src/tool/ls.txt +++ /dev/null @@ -1 +0,0 @@ -Lists files and directories in a given path. The path parameter must be absolute; omit it to use the current workspace directory. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. diff --git a/packages/web/src/content/docs/ar/modes.mdx b/packages/web/src/content/docs/ar/modes.mdx index ac57b98e96..ed17670a55 100644 --- a/packages/web/src/content/docs/ar/modes.mdx +++ b/packages/web/src/content/docs/ar/modes.mdx @@ -233,7 +233,6 @@ Provide constructive feedback without making direct changes. | `read` | قراءة محتويات الملفات | | `grep` | البحث في محتويات الملفات | | `glob` | العثور على الملفات حسب نمط | -| `list` | سرد محتويات الدليل | | `patch` | تطبيق تصحيحات على الملفات | | `todowrite` | إدارة قوائم المهام | | `webfetch` | جلب محتوى الويب | diff --git a/packages/web/src/content/docs/ar/permissions.mdx b/packages/web/src/content/docs/ar/permissions.mdx index 4391514b43..bb21d00b24 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -88,7 +88,7 @@ description: تحكّم في الإجراءات التي تتطلب موافقة ### الأدلة الخارجية -استخدم `external_directory` للسماح باستدعاءات الأدوات التي تلمس مسارات خارج دليل العمل الذي بدأ منه OpenCode. ينطبق ذلك على أي أداة تأخذ مسارًا كمدخل (مثل `read` و`edit` و`list` و`glob` و`grep` والعديد من أوامر `bash`). +استخدم `external_directory` للسماح باستدعاءات الأدوات التي تلمس مسارات خارج دليل العمل الذي بدأ منه OpenCode. ينطبق ذلك على أي أداة تأخذ مسارًا كمدخل (مثل `read` و`edit` و`glob` و`grep` والعديد من أوامر `bash`). توسيع المنزل (مثل `~/...`) يؤثر فقط على طريقة كتابة النمط. لا يجعل ذلك المسار الخارجي جزءًا من مساحة العمل الحالية، لذا يجب السماح بالمسارات خارج دليل العمل عبر `external_directory` أيضًا. @@ -133,7 +133,6 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `edit` — جميع تعديلات الملفات (يشمل `edit` و`write` و`patch` و`multiedit`) - `glob` — مطابقة أسماء الملفات (يطابق نمط الـ glob) - `grep` — البحث في المحتوى (يطابق نمط regex) -- `list` — سرد الملفات في دليل (يطابق مسار الدليل) - `bash` — تشغيل أوامر shell (يطابق الأوامر المُحلَّلة مثل `git status --porcelain`) - `task` — تشغيل وكلاء فرعيين (يطابق نوع الوكيل الفرعي) - `skill` — تحميل مهارة (يطابق اسم المهارة) diff --git a/packages/web/src/content/docs/ar/tools.mdx b/packages/web/src/content/docs/ar/tools.mdx index d820778b40..f1477a08c2 100644 --- a/packages/web/src/content/docs/ar/tools.mdx +++ b/packages/web/src/content/docs/ar/tools.mdx @@ -149,22 +149,6 @@ description: إدارة الأدوات التي يمكن لـ LLM استخدام ابحث عن الملفات باستخدام أنماط glob مثل `**/*.js` أو `src/**/*.ts`. يعيد مسارات الملفات المطابقة مرتبة حسب وقت التعديل. ---- - -### list - -اعرض قائمة بالملفات والمجلدات في مسار محدد. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -تعرض هذه الأداة محتويات المجلد. وتقبل أنماط glob لتصفية النتائج. --- @@ -341,7 +325,7 @@ OPENCODE_ENABLE_EXA=1 opencode ## التفاصيل الداخلية -داخليا، تستخدم أدوات مثل `grep` و`glob` و`list` أداة [ripgrep](https://github.com/BurntSushi/ripgrep) في الخلفية. افتراضيا، يحترم ripgrep أنماط `.gitignore`، ما يعني أن الملفات والمجلدات المدرجة في `.gitignore` ستُستبعد من عمليات البحث وعرض القوائم. +داخليا، تستخدم أدوات مثل `grep` و`glob` [ripgrep](https://github.com/BurntSushi/ripgrep) في الخلفية. افتراضيا، يحترم ripgrep أنماط `.gitignore`، ما يعني أن الملفات والمجلدات المدرجة في `.gitignore` ستُستبعد من عمليات البحث وعرض القوائم. --- diff --git a/packages/web/src/content/docs/bs/modes.mdx b/packages/web/src/content/docs/bs/modes.mdx index 6bf4bd27ca..d5f92a9f67 100644 --- a/packages/web/src/content/docs/bs/modes.mdx +++ b/packages/web/src/content/docs/bs/modes.mdx @@ -219,7 +219,6 @@ Ovdje su svi alati koji se mogu kontrolirati kroz konfiguraciju načina rada. | `read` | Pročitajte sadržaj datoteke | | `grep` | Pretraži sadržaj datoteke | | `glob` | Pronađite datoteke po uzorku | -| `list` | Lista sadržaja direktorija | | `patch` | Primijenite zakrpe na datoteke | | `todowrite` | Upravljanje listama zadataka | | `webfetch` | Dohvati web sadržaj | diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx index b6a194ad28..e27fa130b3 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -87,7 +87,7 @@ Možete koristiti `~` ili `$HOME` na početku obrasca da referencirate svoj poč ### Vanjski direktoriji -Koristite `external_directory` da dozvolite pozive alata koji dodiruju putanje izvan radnog direktorija gdje je OpenCode pokrenut. Ovo se odnosi na bilo koji alat koji uzima putanju kao ulaz (na primjer `read`, `edit`, `list`, `glob`, `grep` i mnoge `bash` komande). +Koristite `external_directory` da dozvolite pozive alata koji dodiruju putanje izvan radnog direktorija gdje je OpenCode pokrenut. Ovo se odnosi na bilo koji alat koji uzima putanju kao ulaz (na primjer `read`, `edit`, `glob`, `grep` i mnoge `bash` komande). Proširenje kuće (poput `~/...`) utiče samo na način na koji je obrazac napisan. Ne čini vanjsku stazu dijelom trenutnog radnog prostora, tako da staze izvan radnog direktorija i dalje moraju biti dozvoljene preko `external_directory`. Na primjer, ovo omogućava pristup svemu pod `~/projects/personal/`: @@ -128,7 +128,6 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `edit` — sve izmjene fajlova (pokriva `edit`, `write`, `patch`, `multiedit`) - `glob` — globbiranje fajla (odgovara glob uzorku) - `grep` — pretraga sadržaja (podudara se sa regularnim izrazom) -- `list` — lista fajlova u direktorijumu (podudara se sa putanjom direktorijuma) - `bash` — izvođenje komandi ljuske (podudara se s raščlanjenim komandama kao što je `git status --porcelain`) - `task` — pokretanje subagenta (odgovara tipu podagenta) - `skill` — učitavanje vještine (odgovara nazivu vještine) diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx index d0ae9a4460..6c4d546141 100644 --- a/packages/web/src/content/docs/bs/tools.mdx +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -149,22 +149,6 @@ Pronalazi datoteke po obrascima. Trazi datoteke koristeci glob obrasce kao `**/*.js` ili `src/**/*.ts`. Vraca putanje sortirane po vremenu izmjene. ---- - -### list - -Ispisuje datoteke i direktorije na zadanoj putanji. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Ovaj alat ispisuje sadrzaj direktorija. Prihvata glob obrasce za filtriranje rezultata. --- @@ -341,7 +325,7 @@ MCP (Model Context Protocol) serveri omogucavaju integraciju eksternih alata i s ## Interno -Interno, alati kao `grep`, `glob` i `list` koriste [ripgrep](https://github.com/BurntSushi/ripgrep). Po defaultu, ripgrep postuje `.gitignore` obrasce, pa se fajlovi i direktoriji iz `.gitignore` izostavljaju iz pretraga i listinga. +Interno, alati kao `grep` i `glob` koriste [ripgrep](https://github.com/BurntSushi/ripgrep). Po defaultu, ripgrep postuje `.gitignore` obrasce, pa se fajlovi i direktoriji iz `.gitignore` izostavljaju iz pretraga i listinga. --- diff --git a/packages/web/src/content/docs/da/modes.mdx b/packages/web/src/content/docs/da/modes.mdx index 34fb2b3595..a0fb87a862 100644 --- a/packages/web/src/content/docs/da/modes.mdx +++ b/packages/web/src/content/docs/da/modes.mdx @@ -233,7 +233,6 @@ Her er alle de værktøjer, der kan styres gennem tilstandskonfigurationen. | `read` | Læs filindhold | | `grep` | Søg filindhold | | `glob` | Find filer efter mønster | -| `list` | Liste biblioteksindhold | | `patch` | Anvend patches til filer | | `todowrite` | Administrer todo-lister | | `webfetch` | Hent webindhold | diff --git a/packages/web/src/content/docs/da/permissions.mdx b/packages/web/src/content/docs/da/permissions.mdx index 72ebff606c..176dd568e1 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -88,7 +88,7 @@ Du kan bruge `~` eller `$HOME` i starten af ​​et mønster til at referere ti ### Eksterne mapper -Brug `external_directory` til at tillade værktøjsopkald, der berører stier uden for den arbejdsmappe, hvor OpenCode blev startet. Dette gælder for ethvert værktøj, der tager en sti som input (for eksempel `read`, `edit`, `list`, `glob`, `grep` og mange `bash` kommandoer). +Brug `external_directory` til at tillade værktøjsopkald, der berører stier uden for den arbejdsmappe, hvor OpenCode blev startet. Dette gælder for ethvert værktøj, der tager en sti som input (for eksempel `read`, `edit`, `glob`, `grep` og mange `bash` kommandoer). Hjemmeudvidelse (som `~/...`) påvirker kun, hvordan et mønster skrives. Det gør ikke en ekstern sti til en del af det aktuelle arbejdsområde, så stier uden for arbejdsbiblioteket skal stadig være tilladt via `external_directory`. @@ -133,7 +133,6 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `edit` — alle filændringer (dækker `edit`, `write`, `patch`, `multiedit`) - `glob` — fil-globing (matcher glob-mønsteret) - `grep` — indholdssøgning (matcher regex-mønsteret) -- `list` — viser filer i en mappe (matcher mappestien) - `bash` — kører shell-kommandoer (matcher parsede kommandoer som `git status --porcelain`) - `task` — lancering af underagenter (matcher underagenttypen) - `skill` — indlæsning af en færdighed (matcher færdighedsnavnet) diff --git a/packages/web/src/content/docs/da/tools.mdx b/packages/web/src/content/docs/da/tools.mdx index a610e8cc39..043aabed43 100644 --- a/packages/web/src/content/docs/da/tools.mdx +++ b/packages/web/src/content/docs/da/tools.mdx @@ -149,22 +149,6 @@ Finn filer etter mønstermatching. Søk etter filer ved at bruge glob-mønstre som `**/*.js` eller `src/**/*.ts`. Returnerer samsvarende filbaner sortert etter endringstid. ---- - -### list - -List filer og kataloger i en gitt bane. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Dette verktøyet viser kataloginnhold. Den aksepterer glob-mønstre for at filtrere resultater. --- @@ -341,7 +325,7 @@ MCP (Model Context Protocol) servere lar deg integrere eksterne verktøy og tjen ## Interne -Internt bruger verktøy som `grep`, `glob` og `list` [ripgrep](https://github.com/BurntSushi/ripgrep) under panseret. Som standard respekterer ripgrep `.gitignore`-mønstre, noe som betyr at filer og kataloger som er oppført i `.gitignore` vil bli ekskludert fra søk og lister. +Internt bruger verktøy som `grep` og `glob` [ripgrep](https://github.com/BurntSushi/ripgrep) under panseret. Som standard respekterer ripgrep `.gitignore`-mønstre, noe som betyr at filer og kataloger som er oppført i `.gitignore` vil bli ekskludert fra søk og lister. --- diff --git a/packages/web/src/content/docs/de/modes.mdx b/packages/web/src/content/docs/de/modes.mdx index 38a2e34b38..11a010d6b9 100644 --- a/packages/web/src/content/docs/de/modes.mdx +++ b/packages/web/src/content/docs/de/modes.mdx @@ -233,7 +233,6 @@ Hier sind alle Tools aufgeführt, die über den Konfigurationsmodus gesteuert we | `read` | Dateiinhalt lesen | | `grep` | Dateiinhalte durchsuchen | | `glob` | Dateien nach Muster suchen | -| `list` | Verzeichnisinhalte auflisten | | `patch` | Patches auf Dateien anwenden | | `todowrite` | Aufgabenlisten verwalten | | `webfetch` | Webinhalte abrufen | diff --git a/packages/web/src/content/docs/de/permissions.mdx b/packages/web/src/content/docs/de/permissions.mdx index ba7c802040..6b647ca366 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -88,7 +88,7 @@ Sie können `~` oder `$HOME` am Anfang eines Musters verwenden, um auf Ihr Home- ### Externe Verzeichnisse -Verwenden Sie `external_directory`, um Toolaufrufe zuzulassen, die Pfade außerhalb des Arbeitsverzeichnisses berühren, in dem OpenCode gestartet wurde. Dies gilt für jedes Werkzeug, das einen Pfad als Eingabe verwendet (z. B. `read`, `edit`, `list`, `glob`, `grep` und viele `bash`-Befehle). +Verwenden Sie `external_directory`, um Toolaufrufe zuzulassen, die Pfade außerhalb des Arbeitsverzeichnisses berühren, in dem OpenCode gestartet wurde. Dies gilt für jedes Werkzeug, das einen Pfad als Eingabe verwendet (z. B. `read`, `edit`, `glob`, `grep` und viele `bash`-Befehle). Die Home-Erweiterung (wie `~/...`) wirkt sich nur darauf aus, wie ein Muster geschrieben wird. Dadurch wird ein externer Pfad nicht zum Teil des aktuellen Arbeitsbereichs, daher müssen Pfade außerhalb des Arbeitsverzeichnisses weiterhin über `external_directory` zulässig sein. @@ -133,7 +133,6 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `edit` – alle Dateiänderungen (umfasst `edit`, `write`, `patch`, `multiedit`) - `glob` – Datei-Globbing (entspricht dem Glob-Muster) - `grep` – Inhaltssuche (entspricht dem Regex-Muster) -- `list` – Auflistung der Dateien in einem Verzeichnis (entspricht dem Verzeichnispfad) - `bash` – Ausführen von Shell-Befehlen (entspricht analysierten Befehlen wie `git status --porcelain`) - `task` – Subagenten starten (entspricht dem Subagententyp) - `skill` – Laden einer Fertigkeit (entspricht dem Fertigkeitsnamen) diff --git a/packages/web/src/content/docs/de/tools.mdx b/packages/web/src/content/docs/de/tools.mdx index b33163df85..98f5c708c2 100644 --- a/packages/web/src/content/docs/de/tools.mdx +++ b/packages/web/src/content/docs/de/tools.mdx @@ -156,22 +156,6 @@ Findet Dateien per Musterabgleich. Sucht nach Dateien mit Glob-Mustern wie `**/*.js` oder `src/**/*.ts`. Gibt passende Dateipfade sortiert nach Aenderungsdatum zurueck. ---- - -### list - -Listet Dateien und Verzeichnisse in einem Pfad auf. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Dieses Tool listet Verzeichnisinhalte auf. Es akzeptiert Glob-Muster zum Filtern der Ergebnisse. --- @@ -350,7 +334,7 @@ Dazu gehoeren Datenbanken, API-Integrationen und Drittanbieter-Services. ## Interna -Intern verwenden Tools wie `grep`, `glob` und `list` [ripgrep](https://github.com/BurntSushi/ripgrep). +Intern verwenden Tools wie `grep` und `glob` [ripgrep](https://github.com/BurntSushi/ripgrep). Standardmaessig beachtet ripgrep `.gitignore`, daher werden dort aufgefuehrte Dateien und Ordner nicht durchsucht. --- diff --git a/packages/web/src/content/docs/es/modes.mdx b/packages/web/src/content/docs/es/modes.mdx index cefc4a4e2d..dca900dff0 100644 --- a/packages/web/src/content/docs/es/modes.mdx +++ b/packages/web/src/content/docs/es/modes.mdx @@ -233,7 +233,6 @@ Aquí están todas las herramientas que se pueden controlar a través del modo d | `read` | Leer el contenido del archivo | | `grep` | Buscar contenido del archivo | | `glob` | Buscar archivos por patrón | -| `list` | Listar el contenido del directorio | | `patch` | Aplicar parches a archivos | | `todowrite` | Administrar listas de tareas pendientes | | `webfetch` | Obtener contenido web | diff --git a/packages/web/src/content/docs/es/permissions.mdx b/packages/web/src/content/docs/es/permissions.mdx index 603b3bdb3f..6923368e40 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -88,7 +88,7 @@ Puede usar `~` o `$HOME` al comienzo de un patrón para hacer referencia a su di ### Directorios externos -Utilice `external_directory` para permitir llamadas a herramientas que toquen rutas fuera del directorio de trabajo donde se inició OpenCode. Esto se aplica a cualquier herramienta que tome una ruta como entrada (por ejemplo, `read`, `edit`, `list`, `glob`, `grep` y muchos comandos `bash`). +Utilice `external_directory` para permitir llamadas a herramientas que toquen rutas fuera del directorio de trabajo donde se inició OpenCode. Esto se aplica a cualquier herramienta que tome una ruta como entrada (por ejemplo, `read`, `edit`, `glob`, `grep` y muchos comandos `bash`). La expansión del hogar (como `~/...`) solo afecta la forma en que se escribe un patrón. No hace que una ruta externa forme parte del espacio de trabajo actual, por lo que las rutas fuera del directorio de trabajo aún deben permitirse a través de `external_directory`. @@ -133,7 +133,6 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `edit` — todas las modificaciones de archivos (cubre `edit`, `write`, `patch`, `multiedit`) - `glob` — globalización de archivos (coincide con el patrón global) - `grep` — búsqueda de contenido (coincide con el patrón de expresiones regulares) -- `list` — enumerar archivos en un directorio (coincide con la ruta del directorio) - `bash`: ejecuta comandos de shell (coincide con comandos analizados como `git status --porcelain`) - `task` — lanzamiento de subagentes (coincide con el tipo de subagente) - `skill` — cargar una habilidad (coincide con el nombre de la habilidad) diff --git a/packages/web/src/content/docs/es/tools.mdx b/packages/web/src/content/docs/es/tools.mdx index f3a050c03b..7d594a1c9f 100644 --- a/packages/web/src/content/docs/es/tools.mdx +++ b/packages/web/src/content/docs/es/tools.mdx @@ -149,22 +149,6 @@ Encuentre archivos por coincidencia de patrones. Busque archivos usando patrones globales como `**/*.js` o `src/**/*.ts`. Devuelve rutas de archivos coincidentes ordenadas por hora de modificación. ---- - -### list - -Enumere archivos y directorios en una ruta determinada. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Esta herramienta enumera el contenido del directorio. Acepta patrones globales para filtrar resultados. --- @@ -341,7 +325,7 @@ Los servidores MCP (Model Context Protocol) le permiten integrar herramientas y ## Internos -Internamente, herramientas como `grep`, `glob` y `list` usan [ripgrep](https://github.com/BurntSushi/ripgrep) bajo el capó. De forma predeterminada, ripgrep respeta los patrones `.gitignore`, lo que significa que los archivos y directorios enumerados en su `.gitignore` se excluirán de las búsquedas y listados. +Internamente, herramientas como `grep` y `glob` usan [ripgrep](https://github.com/BurntSushi/ripgrep) bajo el capó. De forma predeterminada, ripgrep respeta los patrones `.gitignore`, lo que significa que los archivos y directorios enumerados en su `.gitignore` se excluirán de las búsquedas y listados. --- diff --git a/packages/web/src/content/docs/fr/modes.mdx b/packages/web/src/content/docs/fr/modes.mdx index 8c3ad62e41..6985dbd57d 100644 --- a/packages/web/src/content/docs/fr/modes.mdx +++ b/packages/web/src/content/docs/fr/modes.mdx @@ -231,7 +231,6 @@ Voici tous les outils pouvant être contrôlés via le mode config. | `read` | Lire le contenu du fichier | | `grep` | Rechercher le contenu du fichier | | `glob` | Rechercher des fichiers par modèle | -| `list` | Liste du contenu du répertoire | | `patch` | Appliquer des correctifs aux fichiers | | `todowrite` | Gérer les listes de tâches | | `webfetch` | Récupérer du contenu Web | diff --git a/packages/web/src/content/docs/fr/permissions.mdx b/packages/web/src/content/docs/fr/permissions.mdx index 176fa34ad2..b1c1d6800f 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -88,7 +88,7 @@ Vous pouvez utiliser `~` ou `$HOME` au début d'un modèle pour référencer vot ### Répertoires externes -Utilisez `external_directory` pour autoriser les appels d'outils qui touchent des chemins en dehors du répertoire de travail où OpenCode a été démarré. Cela s'applique à tout outil qui prend un chemin en entrée (par exemple `read`, `edit`, `list`, `glob`, `grep` et de nombreuses commandes `bash`). +Utilisez `external_directory` pour autoriser les appels d'outils qui touchent des chemins en dehors du répertoire de travail où OpenCode a été démarré. Cela s'applique à tout outil qui prend un chemin en entrée (par exemple `read`, `edit`, `glob`, `grep` et de nombreuses commandes `bash`). L'expansion du répertoire personnel (comme `~/...`) n'affecte que la façon dont un modèle est écrit. Cela n'intègre pas un chemin externe à l'espace de travail actuel, donc les chemins en dehors du répertoire de travail doivent toujours être autorisés via `external_directory`. @@ -133,7 +133,6 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `edit` — toutes les modifications de fichiers (couvre `edit`, `write`, `patch`, `multiedit`) - `glob` — globalisation de fichiers (correspond au modèle global) - `grep` — recherche de contenu (correspond au modèle regex) -- `list` — listant les fichiers dans un répertoire (correspond au chemin du répertoire) - `bash` - exécution de commandes shell (correspond aux commandes analysées comme `git status --porcelain`) - `task` — lancement de sous-agents (correspond au type de sous-agent) - `skill` — chargement d'une compétence (correspond au nom de la compétence) diff --git a/packages/web/src/content/docs/fr/tools.mdx b/packages/web/src/content/docs/fr/tools.mdx index 62579c2bf8..483a953443 100644 --- a/packages/web/src/content/docs/fr/tools.mdx +++ b/packages/web/src/content/docs/fr/tools.mdx @@ -149,22 +149,6 @@ Recherchez des fichiers par correspondance de modèles. Recherchez des fichiers à l'aide de modèles globaux tels que `**/*.js` ou `src/**/*.ts`. Renvoie les chemins de fichiers correspondants triés par heure de modification. ---- - -### liste - -Répertoriez les fichiers et les répertoires dans un chemin donné. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Cet outil répertorie le contenu du répertoire. Il accepte les modèles globaux pour filtrer les résultats. --- @@ -341,7 +325,7 @@ Les serveurs MCP (Model Context Protocol) vous permettent d'intégrer des outils ## Internes -En interne, des outils comme `grep`, `glob` et `list` utilisent [ripgrep](https://github.com/BurntSushi/ripgrep) sous le capot. Par défaut, ripgrep respecte les modèles `.gitignore`, ce qui signifie que les fichiers et répertoires répertoriés dans votre `.gitignore` seront exclus des recherches et des listes. +En interne, des outils comme `grep` et `glob` utilisent [ripgrep](https://github.com/BurntSushi/ripgrep) sous le capot. Par défaut, ripgrep respecte les modèles `.gitignore`, ce qui signifie que les fichiers et répertoires répertoriés dans votre `.gitignore` seront exclus des recherches et des listes. --- diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx index 8f5c22d6e4..b72b388fb0 100644 --- a/packages/web/src/content/docs/it/modes.mdx +++ b/packages/web/src/content/docs/it/modes.mdx @@ -232,7 +232,6 @@ Ecco tutti gli strumenti che possono essere controllati tramite la configurazion | `read` | Legge contenuti dei file | | `grep` | Cerca nei contenuti dei file | | `glob` | Trova file per pattern | -| `list` | Elenca contenuti di una directory | | `patch` | Applica patch ai file | | `todowrite` | Gestisce liste todo | | `webfetch` | Recupera contenuti web | diff --git a/packages/web/src/content/docs/it/permissions.mdx b/packages/web/src/content/docs/it/permissions.mdx index 3f255c89dd..49f0e8e4d3 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -88,7 +88,7 @@ Puoi usare `~` o `$HOME` all'inizio di un pattern per riferirti alla tua home di ### Directory esterne -Usa `external_directory` per consentire chiamate a strumenti che toccano percorsi al di fuori della directory di lavoro da cui e' stato avviato OpenCode. Si applica a qualsiasi strumento che accetta un path come input (ad esempio `read`, `edit`, `list`, `glob`, `grep` e molti comandi `bash`). +Usa `external_directory` per consentire chiamate a strumenti che toccano percorsi al di fuori della directory di lavoro da cui e' stato avviato OpenCode. Si applica a qualsiasi strumento che accetta un path come input (ad esempio `read`, `edit`, `glob`, `grep` e molti comandi `bash`). L'espansione della home (come `~/...`) influisce solo su come viene scritto un pattern. Non rende un percorso esterno parte della workspace corrente, quindi i path fuori dalla directory di lavoro devono comunque essere consentiti tramite `external_directory`. @@ -133,7 +133,6 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `edit` — tutte le modifiche ai file (include `edit`, `write`, `patch`, `multiedit`) - `glob` — ricerca file tramite glob (corrisponde al pattern glob) - `grep` — ricerca nel contenuto (corrisponde al pattern regex) -- `list` — elenco file in una directory (corrisponde al path della directory) - `bash` — esecuzione comandi di shell (corrisponde a comandi parsati come `git status --porcelain`) - `task` — avvio subagenti (corrisponde al tipo di subagente) - `skill` — caricamento di una skill (corrisponde al nome della skill) diff --git a/packages/web/src/content/docs/it/tools.mdx b/packages/web/src/content/docs/it/tools.mdx index 50609fd616..0bf00ffc6f 100644 --- a/packages/web/src/content/docs/it/tools.mdx +++ b/packages/web/src/content/docs/it/tools.mdx @@ -149,22 +149,6 @@ Trova file tramite pattern matching. Cerca file usando pattern glob come `**/*.js` o `src/**/*.ts`. Restituisce i percorsi corrispondenti ordinati per data di modifica. ---- - -### list - -Elenca file e directory in un percorso specifico. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Questo strumento elenca il contenuto di una directory. Accetta pattern glob per filtrare i risultati. --- @@ -341,7 +325,7 @@ I server MCP (Model Context Protocol) permettono di integrare strumenti e serviz ## Interni -Internamente, strumenti come `grep`, `glob` e `list` usano [ripgrep](https://github.com/BurntSushi/ripgrep) sotto al cofano. Di default, ripgrep rispetta i pattern di `.gitignore`, quindi i file e le directory elencati in `.gitignore` vengono esclusi da ricerche ed elenchi. +Internamente, strumenti come `grep` e `glob` usano [ripgrep](https://github.com/BurntSushi/ripgrep) sotto al cofano. Di default, ripgrep rispetta i pattern di `.gitignore`, quindi i file e le directory elencati in `.gitignore` vengono esclusi da ricerche ed elenchi. --- diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx index c9f2a4d5ee..623c19552d 100644 --- a/packages/web/src/content/docs/ja/modes.mdx +++ b/packages/web/src/content/docs/ja/modes.mdx @@ -231,7 +231,6 @@ Markdown ファイル名はモード名になります (例: `review.md` は `re | `read` | ファイルの内容を読み取る | | `grep` | ファイルの内容を検索 | | `glob` | パターンでファイルを検索 | -| `list` | ディレクトリの内容をリストする | | `patch` | ファイルにパッチを適用する | | `todowrite` | ToDo リストを管理する | | `webfetch` | Web コンテンツを取得する | diff --git a/packages/web/src/content/docs/ja/permissions.mdx b/packages/web/src/content/docs/ja/permissions.mdx index 5f5df6675c..f2b0978259 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -88,7 +88,7 @@ OpenCode は `permission` 設定を使用して、特定のアクションを自 ### 外部ディレクトリ -`external_directory` を使用して、OpenCode が開始された作業ディレクトリの外部のパスに触れるツール呼び出しを許可します。これは、パスを入力として受け取るすべてのツール (`read`、`edit`、`list`、`glob`、`grep`、および多くの `bash` コマンドなど) に適用されます。 +`external_directory` を使用して、OpenCode が開始された作業ディレクトリの外部のパスに触れるツール呼び出しを許可します。これは、パスを入力として受け取るすべてのツール (`read`、`edit`、`glob`、`grep`、および多くの `bash` コマンドなど) に適用されます。 ホーム展開 (`~/...` など) は、パターンの記述方法にのみ影響します。外部パスは現在のワークスペースの一部にはならないため、作業ディレクトリの外部のパスも `external_directory` 経由で許可する必要があります。 @@ -133,7 +133,6 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `edit` — すべてのファイル変更 (`edit`、`write`、`patch`、`multiedit` をカバー) - `glob` — ファイルのグロビング (グロブパターンと一致) - `grep` — コンテンツ検索 (正規表現パターンと一致) -- `list` — ディレクトリ内のファイルのリスト (ディレクトリパスと一致) - `bash` — シェルコマンドの実行 (`git status --porcelain` などの解析されたコマンドと一致します) - `task` — サブエージェントの起動 (サブエージェントのタイプと一致) - `skill` — スキルをロードしています(スキル名と一致します) diff --git a/packages/web/src/content/docs/ja/tools.mdx b/packages/web/src/content/docs/ja/tools.mdx index 0e0f8fe951..ae409aa7db 100644 --- a/packages/web/src/content/docs/ja/tools.mdx +++ b/packages/web/src/content/docs/ja/tools.mdx @@ -149,22 +149,6 @@ OpenCode で利用可能なすべての組み込みツールを次に示しま `**/*.js` や `src/**/*.ts` などの glob パターンを使用してファイルを検索します。一致するファイルパスを変更時間順に並べて返します。 ---- - -### list - -指定されたパス内のファイルとディレクトリを一覧表示します。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -このツールはディレクトリの内容を一覧表示します。結果をフィルタリングするための glob パターンを受け入れます。 --- @@ -341,7 +325,7 @@ MCP (Model Context Protocol) サーバーを使用すると、外部ツールと ## 内部動作 -内部的には、`grep`、`glob`、`list` などのツールは内部で [ripgrep](https://github.com/BurntSushi/ripgrep) を使用します。デフォルトでは、ripgrep は `.gitignore` パターンを尊重します。つまり、`.gitignore` にリストされているファイルとディレクトリは検索とリストから除外されます。 +内部的には、`grep`、`glob` などのツールは内部で [ripgrep](https://github.com/BurntSushi/ripgrep) を使用します。デフォルトでは、ripgrep は `.gitignore` パターンを尊重します。つまり、`.gitignore` にリストされているファイルとディレクトリは検索とリストから除外されます。 --- diff --git a/packages/web/src/content/docs/ko/modes.mdx b/packages/web/src/content/docs/ko/modes.mdx index 35bc4d2264..32746d1d05 100644 --- a/packages/web/src/content/docs/ko/modes.mdx +++ b/packages/web/src/content/docs/ko/modes.mdx @@ -232,7 +232,6 @@ Markdown 파일 이름은 모드 이름 (예 : `review.md`는 `review` 모드를 | `read` | 읽는 파일 내용 | | `grep` | 파일 검색 | | `glob` | 패턴으로 찾기 | -| `list` | 디렉토리 내용 보기 | | `patch` | 파일에 패치 적용 | | `todowrite` | 할 일(Todo) 목록 관리 | | `webfetch` | 웹사이트 가져오기 | diff --git a/packages/web/src/content/docs/ko/permissions.mdx b/packages/web/src/content/docs/ko/permissions.mdx index ec129f45c0..0742089d6b 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -88,7 +88,7 @@ Permission 본 사용 간단한 wildcard 일치: ## 외부 디렉터리 -`external_directory`를 사용하여 도구가 opencode가 시작된 작업 디렉토리 밖에 터치 경로가 호출되도록합니다. 이것은 입력 (예 : `read`, `edit`, `list`, `glob`, `glob`, `grep` 및 많은 `bash` 명령)로 경로를 수행하는 모든 도구에 적용됩니다. +`external_directory`를 사용하여 도구가 opencode가 시작된 작업 디렉토리 밖에 터치 경로가 호출되도록합니다. 이것은 입력 (예 : `read`, `edit`, `glob`, `grep` 및 많은 `bash` 명령)로 경로를 수행하는 모든 도구에 적용됩니다. 홈 확장 (`~/...`와 같은) 패턴이 작성된 방법에 영향을 미칩니다. 그것은 현재의 작업 공간의 외부 경로 부분을 만들지 않습니다, 그래서 작업 디렉토리 외부 경로는 여전히 `external_directory`를 통해 허용해야합니다. @@ -133,7 +133,6 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `edit` - 모든 파일 수정 (covers `edit`, `write`, `patch`, `multiedit`) - `glob` - 파일 globbing (glob 패턴 매칭) - `grep` - 콘텐츠 검색 ( regex 패턴 매칭) -- `list` - 디렉토리의 목록 파일 (폴더 경로 매칭) - `bash` - shell 명령 실행 (`git status --porcelain`와 같은 팟 명령) - `task` - 에이전트 실행 (작업 에이전트 유형) - `skill` - 기술을 로딩 (기술 이름을 매칭) diff --git a/packages/web/src/content/docs/ko/tools.mdx b/packages/web/src/content/docs/ko/tools.mdx index 33976b66ff..b98578b58e 100644 --- a/packages/web/src/content/docs/ko/tools.mdx +++ b/packages/web/src/content/docs/ko/tools.mdx @@ -149,22 +149,6 @@ Codebase에서 빠른 콘텐츠 검색. 전체 regex 문법 및 파일 패턴 `**/*.js` 또는 `src/**/*.ts`와 같은 glob 패턴을 사용하여 파일 검색. 수정 시간에 의해 정렬 된 파일 경로 반환. ---- - -### list - -주어진 경로의 파일 및 디렉토리 목록. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -이 도구는 디렉토리 내용을 나열합니다. 그것은 glob 패턴을 필터 결과에 받아들입니다. --- @@ -341,7 +325,7 @@ MCP 서버 구성에 대한 [Learn more](/docs/mcp-servers). ## 내부 -내부, 도구 `grep`, `glob`, 그리고 `list` 사용 [ripgrep](https://github.com/BurntSushi/ripgrep) 후드 아래에. 기본적으로 ripgrep은 `.gitignore` 패턴을 존중하며 `.gitignore`에 나열된 파일 및 디렉토리를 검색 및 목록에서 제외됩니다. +내부, 도구 `grep` 그리고 `glob` 사용 [ripgrep](https://github.com/BurntSushi/ripgrep) 후드 아래에. 기본적으로 ripgrep은 `.gitignore` 패턴을 존중하며 `.gitignore`에 나열된 파일 및 디렉토리를 검색 및 목록에서 제외됩니다. --- diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index 5f23df2540..8ce2c0d13e 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -233,7 +233,6 @@ Here are all the tools can be controlled through the mode config. | `read` | Read file contents | | `grep` | Search file contents | | `glob` | Find files by pattern | -| `list` | List directory contents | | `patch` | Apply patches to files | | `todowrite` | Manage todo lists | | `webfetch` | Fetch web content | diff --git a/packages/web/src/content/docs/nb/modes.mdx b/packages/web/src/content/docs/nb/modes.mdx index bf73ff040f..e99c511be6 100644 --- a/packages/web/src/content/docs/nb/modes.mdx +++ b/packages/web/src/content/docs/nb/modes.mdx @@ -232,7 +232,6 @@ Her er alle verktøyene som kan kontrolleres gjennom moduskonfigurasjonen. | `read` | Les filinnhold | | `grep` | Søk i filinnhold | | `glob` | Finn filer etter mønster | -| `list` | List opp kataloginnhold | | `patch` | Bruk patcher på filer | | `todowrite` | Administrer gjøremålslister | | `webfetch` | Hent webinnhold | diff --git a/packages/web/src/content/docs/nb/permissions.mdx b/packages/web/src/content/docs/nb/permissions.mdx index 6437555a2f..5c63b251e3 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -88,7 +88,7 @@ Du kan bruke `~` eller `$HOME` i starten av et mønster for å referere til hjem ### Eksterne kataloger -Bruk `external_directory` for å tillate verktøyanrop som berører stier utenfor arbeidskatalogen der OpenCode ble startet. Dette gjelder alle verktøy som tar en bane som input (for eksempel `read`, `edit`, `list`, `glob`, `grep` og mange `bash`-kommandoer). +Bruk `external_directory` for å tillate verktøyanrop som berører stier utenfor arbeidskatalogen der OpenCode ble startet. Dette gjelder alle verktøy som tar en bane som input (for eksempel `read`, `edit`, `glob`, `grep` og mange `bash`-kommandoer). Hjemmeutvidelse (som `~/...`) påvirker bare hvordan et mønster skrives. Den gjør ikke en ekstern bane til en del av det gjeldende arbeidsområdet, så stier utenfor arbeidskatalogen må fortsatt tillates via `external_directory`. @@ -133,7 +133,6 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `edit` — alle filendringer (dekker `edit`, `write`, `patch`, `multiedit`) - `glob` — fil-globing (tilsvarer glob-mønsteret) - `grep` — innholdssøk (samsvarer med regex-mønsteret) -- `list` — viser filer i en katalog (tilsvarer katalogbanen) - `bash` — kjører skallkommandoer (matcher analyserte kommandoer som `git status --porcelain`) - `task` — start av subagenter (tilsvarer subagenttypen) - `skill` — laster en ferdighet (tilsvarer navnet på ferdigheten) diff --git a/packages/web/src/content/docs/nb/tools.mdx b/packages/web/src/content/docs/nb/tools.mdx index be80a0e2ba..2a67378e0a 100644 --- a/packages/web/src/content/docs/nb/tools.mdx +++ b/packages/web/src/content/docs/nb/tools.mdx @@ -149,22 +149,6 @@ Finn filer etter mønstermatching. Søk etter filer ved å bruke glob-mønstre som `**/*.js` eller `src/**/*.ts`. Returnerer samsvarende filbaner sortert etter endringstid. ---- - -### list - -List filer og kataloger i en gitt bane. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Dette verktøyet viser kataloginnhold. Den aksepterer glob-mønstre for å filtrere resultater. --- @@ -341,7 +325,7 @@ MCP (Model Context Protocol) servere lar deg integrere eksterne verktøy og tjen ## Internaler -Internt bruker verktøy som `grep`, `glob` og `list` [ripgrep](https://github.com/BurntSushi/ripgrep) i bakgrunnen. Som standard respekterer ripgrep `.gitignore`-mønstre, noe som betyr at filer og kataloger som er oppført i `.gitignore` vil bli ekskludert fra søk og oppføringer. +Internt bruker verktøy som `grep` og `glob` [ripgrep](https://github.com/BurntSushi/ripgrep) i bakgrunnen. Som standard respekterer ripgrep `.gitignore`-mønstre, noe som betyr at filer og kataloger som er oppført i `.gitignore` vil bli ekskludert fra søk og oppføringer. --- diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index a470fddd76..6383b2a3f2 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -88,7 +88,7 @@ You can use `~` or `$HOME` at the start of a pattern to reference your home dire ### External Directories -Use `external_directory` to allow tool calls that touch paths outside the working directory where OpenCode was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `list`, `glob`, `grep`, and many `bash` commands). +Use `external_directory` to allow tool calls that touch paths outside the working directory where OpenCode was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `glob`, `grep`, and many `bash` commands). Home expansion (like `~/...`) only affects how a pattern is written. It does not make an external path part of the current workspace, so paths outside the working directory must still be allowed via `external_directory`. @@ -133,7 +133,6 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `edit` — all file modifications (covers `edit`, `write`, `patch`, `multiedit`) - `glob` — file globbing (matches the glob pattern) - `grep` — content search (matches the regex pattern) -- `list` — listing files in a directory (matches the directory path) - `bash` — running shell commands (matches parsed commands like `git status --porcelain`) - `task` — launching subagents (matches the subagent type) - `skill` — loading a skill (matches the skill name) diff --git a/packages/web/src/content/docs/pl/modes.mdx b/packages/web/src/content/docs/pl/modes.mdx index b28b160866..8d7c2568d3 100644 --- a/packages/web/src/content/docs/pl/modes.mdx +++ b/packages/web/src/content/docs/pl/modes.mdx @@ -233,7 +233,6 @@ Oto wszystkie narzędzia, które można sterować za pomocą konfiguracji trybó | `read` | Przeczytaj zawartość pliku | | `grep` | Wyszukaj zawartość pliku | | `glob` | Znajdź pliki według wzorca | -| `list` | Lista zawartości katalogu | | `patch` | Zastosuj poprawki do plików | | `todowrite` | Zarządzaj listami rzeczy do wykonania | | `webfetch` | Pobierz zawartość internetową | diff --git a/packages/web/src/content/docs/pl/permissions.mdx b/packages/web/src/content/docs/pl/permissions.mdx index 6a7840ac72..a5c05b6dc6 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -88,7 +88,7 @@ Możesz używać `~` lub `$HOME` na początku wzorca, aby zastosować się do sw ### Katalogi zewnętrzne -Użycie `external_directory`, aby zezwolić na wywołanie narzędzia, które obsługuje obsługę poza katalogiem roboczym, z uruchomieniem opencode. Dotyczy każdego narzędzia, które jako dane wejściowe zostało przyjęte (na przykład `read`, `edit`, `list`, `glob`, `grep` i wiele założycieli `bash`). +Użycie `external_directory`, aby zezwolić na wywołanie narzędzia, które obsługuje obsługę poza katalogiem roboczym, z uruchomieniem opencode. Dotyczy każdego narzędzia, które jako dane wejściowe zostało przyjęte (na przykład `read`, `edit`, `glob`, `grep` i wiele założycieli `bash`). Rozszerzenie domu (jak `~/...`) wpływa tylko na sposób za zwyczajowy wzorca. Nie powoduje to, że strategie zewnętrzne stają się stosowane przez `external_directory`. @@ -133,7 +133,6 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `edit` — wszystkie modyfikacje plików (obejmuje `edit`, `write`, `patch`, `multiedit`) - `glob` — maglowanie plików (pasuje do wzorców globowania) - `grep` — wyszukiwanie treści (pasuje do wzorca regularnego) -- `list` — wyświetlanie listy plików w katalogu (pasuje do katalogu) - `bash` — uruchamianie poleceń shell (pasuje do poleceń przeanalizowanych, takich jak `git status --porcelain`) - `task` — uruchamianie podagentów (odpowiada typowi podagenta) - `skill` — ładowanie umiejętności (pasuje do nazwy umiejętności) diff --git a/packages/web/src/content/docs/pl/tools.mdx b/packages/web/src/content/docs/pl/tools.mdx index 649c744e04..0690fb18c3 100644 --- a/packages/web/src/content/docs/pl/tools.mdx +++ b/packages/web/src/content/docs/pl/tools.mdx @@ -149,22 +149,6 @@ Znajduj pliki na podstawie wzorców. Szukaj plików przy użyciu wzorców glob, takich jak `**/*.js` lub `src/**/*.ts`. Zwraca pasujące ścieżki plików posortowane według czasu modyfikacji. ---- - -### list - -Wyświetla listę plików i katalogów w podanej ścieżce. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -To narzędzie wyświetla zawartość katalogu. Akceptuje wzorce glob do filtrowania wyników. --- @@ -341,7 +325,7 @@ Serwery MCP (Model Context Protocol) umożliwiają integrację zewnętrznych nar ## Szczegóły techniczne -Wewnętrznie narzędzia takie jak `grep`, `glob` i `list` używają [ripgrep](https://github.com/BurntSushi/ripgrep). Domyślnie ripgrep respektuje wzorce `.gitignore`, co oznacza, że pliki i katalogi wymienione w Twoim `.gitignore` zostaną wykluczone z wyszukiwań i list. +Wewnętrznie narzędzia takie jak `grep` i `glob` używają [ripgrep](https://github.com/BurntSushi/ripgrep). Domyślnie ripgrep respektuje wzorce `.gitignore`, co oznacza, że pliki i katalogi wymienione w Twoim `.gitignore` zostaną wykluczone z wyszukiwań i list. --- diff --git a/packages/web/src/content/docs/pt-br/modes.mdx b/packages/web/src/content/docs/pt-br/modes.mdx index b549d69ded..b53fb4fcb4 100644 --- a/packages/web/src/content/docs/pt-br/modes.mdx +++ b/packages/web/src/content/docs/pt-br/modes.mdx @@ -230,7 +230,6 @@ Aqui estão todas as ferramentas que podem ser controladas através da configura | `read` | Ler conteúdos de arquivos | | `grep` | Pesquisar conteúdos de arquivos | | `glob` | Encontrar arquivos por padrão | -| `list` | Listar conteúdos de diretório | | `patch` | Aplicar patches a arquivos | | `todowrite` | Gerenciar listas de tarefas | | `webfetch` | Buscar conteúdo da web | diff --git a/packages/web/src/content/docs/pt-br/permissions.mdx b/packages/web/src/content/docs/pt-br/permissions.mdx index c3850c00ca..4facc9f72b 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -88,7 +88,7 @@ Você pode usar `~` ou `$HOME` no início de um padrão para referenciar seu dir ### Diretórios Externos -Use `external_directory` para permitir chamadas de ferramentas que tocam em caminhos fora do diretório de trabalho onde o opencode foi iniciado. Isso se aplica a qualquer ferramenta que aceite um caminho como entrada (por exemplo, `read`, `edit`, `list`, `glob`, `grep` e muitos comandos `bash`). +Use `external_directory` para permitir chamadas de ferramentas que tocam em caminhos fora do diretório de trabalho onde o opencode foi iniciado. Isso se aplica a qualquer ferramenta que aceite um caminho como entrada (por exemplo, `read`, `edit`, `glob`, `grep` e muitos comandos `bash`). A expansão do home (como `~/...`) afeta apenas como um padrão é escrito. Não torna um caminho externo parte do espaço de trabalho atual, então caminhos fora do diretório de trabalho ainda devem ser permitidos via `external_directory`. @@ -133,7 +133,6 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `edit` — todas as modificações de arquivo (cobre `edit`, `write`, `patch`, `multiedit`) - `glob` — globbing de arquivos (corresponde ao padrão glob) - `grep` — busca de conteúdo (corresponde ao padrão regex) -- `list` — listagem de arquivos em um diretório (corresponde ao caminho do diretório) - `bash` — execução de comandos de shell (corresponde a comandos analisados como `git status --porcelain`) - `task` — lançamento de subagentes (corresponde ao tipo de subagente) - `skill` — carregamento de uma habilidade (corresponde ao nome da habilidade) diff --git a/packages/web/src/content/docs/pt-br/tools.mdx b/packages/web/src/content/docs/pt-br/tools.mdx index d762fdf145..43099ab52c 100644 --- a/packages/web/src/content/docs/pt-br/tools.mdx +++ b/packages/web/src/content/docs/pt-br/tools.mdx @@ -149,22 +149,6 @@ Encontre arquivos por correspondência de padrões. Pesquise arquivos usando padrões glob como `**/*.js` ou `src/**/*.ts`. Retorna caminhos de arquivos correspondentes ordenados por tempo de modificação. ---- - -### list - -Liste arquivos e diretórios em um determinado caminho. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Esta ferramenta lista o conteúdo do diretório. Aceita padrões glob para filtrar resultados. --- @@ -341,7 +325,7 @@ Servidores MCP (Model Context Protocol) permitem que você integre ferramentas e ## Internos -Internamente, ferramentas como `grep`, `glob` e `list` usam [ripgrep](https://github.com/BurntSushi/ripgrep) por trás dos panos. Por padrão, o ripgrep respeita padrões `.gitignore`, o que significa que arquivos e diretórios listados em seu `.gitignore` serão excluídos de buscas e listagens. +Internamente, ferramentas como `grep` e `glob` usam [ripgrep](https://github.com/BurntSushi/ripgrep) por trás dos panos. Por padrão, o ripgrep respeita padrões `.gitignore`, o que significa que arquivos e diretórios listados em seu `.gitignore` serão excluídos de buscas e listagens. --- diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx index f1ebca386d..6a4a74ceda 100644 --- a/packages/web/src/content/docs/ru/modes.mdx +++ b/packages/web/src/content/docs/ru/modes.mdx @@ -233,7 +233,6 @@ Provide constructive feedback without making direct changes. | `read` | Read file contents | | `grep` | Search file contents | | `glob` | Find files by pattern | -| `list` | List directory contents | | `patch` | Apply patches to files | | `todowrite` | Manage todo lists | | `webfetch` | Fetch web content | diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx index 70f3a804a2..961a068243 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -88,7 +88,7 @@ opencode использует конфигурацию `permission`, чтобы ### Внешние каталоги -Используйте `external_directory`, чтобы разрешить вызовы инструментов, затрагивающие пути за пределами рабочего каталога, в котором был запущен opencode. Это применимо к любому инструменту, который принимает путь в качестве входных данных (например, `read`, `edit`, `list`, `glob`, `grep` и многие команды `bash`). +Используйте `external_directory`, чтобы разрешить вызовы инструментов, затрагивающие пути за пределами рабочего каталога, в котором был запущен opencode. Это применимо к любому инструменту, который принимает путь в качестве входных данных (например, `read`, `edit`, `glob`, `grep` и многие команды `bash`). Расширение дома (например, `~/...`) влияет только на запись шаблона. Он не делает внешний путь частью текущего рабочего пространства, поэтому пути за пределами рабочего каталога все равно должны быть разрешены через `external_directory`. @@ -133,7 +133,6 @@ opencode использует конфигурацию `permission`, чтобы - `edit` — все модификации файлов (охватывает `edit`, `write`, `patch`, `multiedit`) - `glob` — подстановка файла (соответствует шаблону подстановки) - `grep` — поиск по контенту (соответствует шаблону регулярного выражения) -- `list` — список файлов в каталоге (соответствует пути к каталогу) - `bash` — запуск shell-команд (соответствует проанализированным командам, например `git status --porcelain`) - `task` — запуск субагентов (соответствует типу субагента) - `skill` — загрузка навыка (соответствует названию навыка) diff --git a/packages/web/src/content/docs/ru/tools.mdx b/packages/web/src/content/docs/ru/tools.mdx index def6663fc1..8d4b5bf99e 100644 --- a/packages/web/src/content/docs/ru/tools.mdx +++ b/packages/web/src/content/docs/ru/tools.mdx @@ -149,22 +149,6 @@ description: Управляйте инструментами, которые м Ищите файлы, используя шаблоны glob, например `**/*.js` или `src/**/*.ts`. Возвращает соответствующие пути к файлам, отсортированные по времени изменения. ---- - -### list - -Список файлов и каталогов по заданному пути. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Этот инструмент отображает содержимое каталога. Он принимает шаблоны glob для фильтрации результатов. --- @@ -341,7 +325,7 @@ OPENCODE_ENABLE_EXA=1 opencode ## Внутреннее устройство -Внутренне такие инструменты, как `grep`, `glob` и `list`, используют [ripgrep](https://github.com/BurntSushi/ripgrep). По умолчанию ripgrep учитывает шаблоны `.gitignore`, что означает, что файлы и каталоги, перечисленные в вашем `.gitignore`, будут исключены из поиска и списков. +Внутренне такие инструменты, как `grep` и `glob`, используют [ripgrep](https://github.com/BurntSushi/ripgrep). По умолчанию ripgrep учитывает шаблоны `.gitignore`, что означает, что файлы и каталоги, перечисленные в вашем `.gitignore`, будут исключены из поиска и списков. --- diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx index 2cbb05a26b..1569a5ad12 100644 --- a/packages/web/src/content/docs/th/modes.mdx +++ b/packages/web/src/content/docs/th/modes.mdx @@ -233,7 +233,6 @@ Provide constructive feedback without making direct changes. | `read` | อ่านเนื้อหาไฟล์ | | `grep` | ค้นหาเนื้อหาไฟล์ | | `glob` | ค้นหาไฟล์ตามรูปแบบ | -| `list` | แสดงรายการเนื้อหาไดเร็กทอรี | | `patch` | ใช้แพทช์กับไฟล์ | | `todowrite` | จัดการรายการสิ่งที่ต้องทำ | | `webfetch` | ดึงเนื้อหาเว็บ | diff --git a/packages/web/src/content/docs/th/permissions.mdx b/packages/web/src/content/docs/th/permissions.mdx index adf381dee3..5fed616159 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -88,7 +88,7 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ ### ไดเรกทอรีภายนอก -ใช้ `external_directory` เพื่ออนุญาตการเรียกใช้เครื่องมือที่สัมผัสเส้นทางนอกไดเร็กทอรีการทำงานที่ OpenCode เริ่มทำงาน สิ่งนี้ใช้ได้กับเครื่องมือใดๆ ที่ใช้เส้นทางเป็นอินพุต (เช่น `read`, `edit`, `list`, `glob`, `grep` และคำสั่ง `bash` จำนวนมาก) +ใช้ `external_directory` เพื่ออนุญาตการเรียกใช้เครื่องมือที่สัมผัสเส้นทางนอกไดเร็กทอรีการทำงานที่ OpenCode เริ่มทำงาน สิ่งนี้ใช้ได้กับเครื่องมือใดๆ ที่ใช้เส้นทางเป็นอินพุต (เช่น `read`, `edit`, `glob`, `grep` และคำสั่ง `bash` จำนวนมาก) การขยายบ้าน (เช่น `~/...`) ส่งผลต่อวิธีการเขียนรูปแบบเท่านั้น ไม่ได้ทำให้เส้นทางภายนอกเป็นส่วนหนึ่งของพื้นที่ทำงานปัจจุบัน ดังนั้นเส้นทางภายนอกไดเรกทอรีการทำงานยังต้องได้รับอนุญาตผ่าน `external_directory` @@ -133,7 +133,6 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `edit` — การแก้ไขไฟล์ทั้งหมด (ครอบคลุมถึง `edit`, `write`, `patch`, `multiedit`) - `glob` — ไฟล์ globbing (ตรงกับรูปแบบ glob) - `grep` — การค้นหาเนื้อหา (ตรงกับรูปแบบ regex) -- `list` — แสดงรายการไฟล์ในไดเร็กทอรี (ตรงกับเส้นทางไดเร็กทอรี) - `bash` — การรันคำสั่ง shell (ตรงกับคำสั่งที่แยกวิเคราะห์เช่น `git status --porcelain`) - `task` — การเปิดตัวตัวแทนย่อย (ตรงกับประเภทตัวแทนย่อย) - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) diff --git a/packages/web/src/content/docs/th/tools.mdx b/packages/web/src/content/docs/th/tools.mdx index 17dbd9fdb3..3c9b88c0a1 100644 --- a/packages/web/src/content/docs/th/tools.mdx +++ b/packages/web/src/content/docs/th/tools.mdx @@ -149,22 +149,6 @@ description: จัดการเครื่องมือที่ LLM ส ค้นหาไฟล์โดยใช้รูปแบบ glob เช่น `**/*.js` หรือ `src/**/*.ts` ส่งคืนเส้นทางไฟล์ที่ตรงกันโดยจัดเรียงตามเวลาแก้ไข ---- - -### list - -แสดงรายการไฟล์และไดเร็กทอรีในพาธที่กำหนด - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -เครื่องมือนี้แสดงรายการเนื้อหาไดเร็กทอรี ยอมรับรูปแบบ glob เพื่อกรองผลลัพธ์ --- @@ -341,7 +325,7 @@ OPENCODE_ENABLE_EXA=1 opencode ## ภายใน -ภายใน เครื่องมือต่างๆ เช่น `grep`, `glob` และ `list` ใช้ [ripgrep](https://github.com/BurntSushi/ripgrep) ภายใต้ประทุน ตามค่าเริ่มต้น ripgrep เคารพรูปแบบ `.gitignore` ซึ่งหมายความว่าไฟล์และไดเร็กทอรีที่อยู่ใน `.gitignore` ของคุณจะถูกแยกออกจากการค้นหาและรายการ +ภายใน เครื่องมือต่างๆ เช่น `grep` และ `glob` ใช้ [ripgrep](https://github.com/BurntSushi/ripgrep) ภายใต้ประทุน ตามค่าเริ่มต้น ripgrep เคารพรูปแบบ `.gitignore` ซึ่งหมายความว่าไฟล์และไดเร็กทอรีที่อยู่ใน `.gitignore` ของคุณจะถูกแยกออกจากการค้นหาและรายการ --- diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index abd486aeb6..f05e980b8c 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -151,23 +151,6 @@ Search for files using glob patterns like `**/*.js` or `src/**/*.ts`. Returns ma --- -### list - -List files and directories in a given path. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -This tool lists directory contents. It accepts glob patterns to filter results. - ---- - ### lsp (experimental) Interact with your configured LSP servers to get code intelligence features like definitions, references, hover info, and call hierarchy. @@ -345,7 +328,7 @@ MCP (Model Context Protocol) servers allow you to integrate external tools and s ## Internals -Internally, tools like `grep`, `glob`, and `list` use [ripgrep](https://github.com/BurntSushi/ripgrep) under the hood. By default, ripgrep respects `.gitignore` patterns, which means files and directories listed in your `.gitignore` will be excluded from searches and listings. +Internally, tools like `grep` and `glob` use [ripgrep](https://github.com/BurntSushi/ripgrep) under the hood. By default, ripgrep respects `.gitignore` patterns, which means files and directories listed in your `.gitignore` will be excluded from searches and listings. --- diff --git a/packages/web/src/content/docs/tr/modes.mdx b/packages/web/src/content/docs/tr/modes.mdx index 09538e788a..8f722ec228 100644 --- a/packages/web/src/content/docs/tr/modes.mdx +++ b/packages/web/src/content/docs/tr/modes.mdx @@ -233,7 +233,6 @@ Hiçbir araç belirtilmezse tüm araçlar varsayılan olarak etkindir. | `read` | Dosya içeriğini oku | | `grep` | Dosya içeriğini ara | | `glob` | Dosyaları desene göre bul | -| `list` | Dizinin içeriğini listele | | `patch` | Dosyalara yama uygula | | `todowrite` | Yapılacaklar listelerini yönet | | `webfetch` | Web içeriğini getir | diff --git a/packages/web/src/content/docs/tr/permissions.mdx b/packages/web/src/content/docs/tr/permissions.mdx index f608ce7e0d..976ee0a7ff 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -88,7 +88,7 @@ Ana dizininize referans vermek için bir modelin başlangıcında `~` veya `$HOM ### Harici Dizinler -opencode'un başlatıldığı çalışma dizini dışındaki yollara dokunan araç çağrılarına izin vermek için `external_directory` kullanın. Bu, girdi olarak bir yolu alan tüm araçlar için geçerlidir (örneğin `read`, `edit`, `list`, `glob`, `grep` ve birçok `bash` komutu). +opencode'un başlatıldığı çalışma dizini dışındaki yollara dokunan araç çağrılarına izin vermek için `external_directory` kullanın. Bu, girdi olarak bir yolu alan tüm araçlar için geçerlidir (örneğin `read`, `edit`, `glob`, `grep` ve birçok `bash` komutu). Ana sayfa genişletmesi (`~/...` gibi) yalnızca bir kalıbın nasıl yazıldığını etkiler. Geçerli çalışma alanının harici bir yolunu oluşturmaz, dolayısıyla çalışma dizini dışındaki yollara yine de `external_directory` aracılığıyla izin verilmesi gerekir. @@ -133,7 +133,6 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `edit` — tüm dosya değişiklikleri (`edit`, `write`, `patch`, `multiedit`'yi kapsar) - `glob` — dosya genellemesi (glob düzeniyle eşleşir) - `grep` — içerik arama (regex modeliyle eşleşir) -- `list` — bir dizideki dosyaları listeleme (dizin yoluyla eşleşir) - `bash` — kabuk komutlarını çalıştırma (`git status --porcelain` gibi ayrıştırılmış komutlarla eşleşir) - `task` — alt agent'ların başlatılması (alt agent türüyle eşleşir) - `skill` — bir skill yükleniyor (skill adıyla eşleşir) diff --git a/packages/web/src/content/docs/tr/tools.mdx b/packages/web/src/content/docs/tr/tools.mdx index e65ffec3a2..633abb4a6e 100644 --- a/packages/web/src/content/docs/tr/tools.mdx +++ b/packages/web/src/content/docs/tr/tools.mdx @@ -149,22 +149,6 @@ Desen eşleştirme ile dosya bulur. `**/*.js` veya `src/**/*.ts` gibi glob desenleriyle dosya arar. Eşleşen dosya yollarını değişim zamanına göre sıralar. ---- - -### list - -Verilen yoldaki dosya ve dizinleri listeler. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -Bu araç dizin içeriğini listeler. Sonuçları filtrelemek için glob desenlerini kabul eder. --- @@ -341,7 +325,7 @@ MCP sunucularını yapılandırma için [daha fazla bilgi alın](/docs/mcp-serve ## Dahili detaylar -Dahilde `grep`, `glob` ve `list` gibi araçlar [ripgrep](https://github.com/BurntSushi/ripgrep) kullanır. Varsayılan olarak ripgrep `.gitignore` desenlerine uyar; yani `.gitignore` içindeki dosya ve dizinler arama ve listeleme sonucuna dahil edilmez. +Dahilde `grep` ve `glob` gibi araçlar [ripgrep](https://github.com/BurntSushi/ripgrep) kullanır. Varsayılan olarak ripgrep `.gitignore` desenlerine uyar; yani `.gitignore` içindeki dosya ve dizinler arama ve listeleme sonucuna dahil edilmez. --- diff --git a/packages/web/src/content/docs/zh-cn/modes.mdx b/packages/web/src/content/docs/zh-cn/modes.mdx index 4570c801c7..256cffe811 100644 --- a/packages/web/src/content/docs/zh-cn/modes.mdx +++ b/packages/web/src/content/docs/zh-cn/modes.mdx @@ -230,7 +230,6 @@ Markdown 文件名即为模式名称(例如,`review.md` 创建一个名为 ` | `read` | 读取文件内容 | | `grep` | 搜索文件内容 | | `glob` | 按模式查找文件 | -| `list` | 列出目录内容 | | `patch` | 对文件应用补丁 | | `todowrite` | 管理待办事项列表 | | `webfetch` | 获取网页内容 | diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx index 24104e2a26..f928554f2a 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -88,7 +88,7 @@ OpenCode 使用 `permission` 配置来决定某个操作是否应自动运行、 ### 外部目录 -使用 `external_directory` 允许工具调用访问 OpenCode 启动时工作目录之外的路径。这适用于任何接受路径作为输入的工具(例如 `read`、`edit`、`list`、`glob`、`grep` 以及许多 `bash` 命令)。 +使用 `external_directory` 允许工具调用访问 OpenCode 启动时工作目录之外的路径。这适用于任何接受路径作为输入的工具(例如 `read`、`edit`、`glob`、`grep` 以及许多 `bash` 命令)。 主目录展开(如 `~/...`)仅影响模式的书写方式。它不会将外部路径纳入当前工作空间,因此工作目录之外的路径仍然必须通过 `external_directory` 来允许。 @@ -133,7 +133,6 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `edit` — 所有文件修改(涵盖 `edit`、`write`、`patch`、`multiedit`) - `glob` — 文件通配(匹配通配模式) - `grep` — 内容搜索(匹配正则表达式模式) -- `list` — 列出目录中的文件(匹配目录路径) - `bash` — 运行 shell 命令(匹配解析后的命令,如 `git status --porcelain`) - `task` — 启动子代理(匹配子代理类型) - `skill` — 加载技能(匹配技能名称) diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 4f68a9cf35..c6d6d71940 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -149,22 +149,6 @@ description: 管理 LLM 可以使用的工具。 使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜索文件。返回按修改时间排序的匹配文件路径。 ---- - -### list - -列出指定路径下的文件和目录。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -该工具用于列出目录内容。它接受 glob 模式来过滤结果。 --- @@ -341,7 +325,7 @@ MCP(Model Context Protocol)服务器允许您集成外部工具和服务, ## 内部机制 -在内部,`grep`、`glob` 和 `list` 等工具底层使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。默认情况下,ripgrep 遵循 `.gitignore` 中的模式,这意味着 `.gitignore` 中列出的文件和目录将被排除在搜索和列表结果之外。 +在内部,`grep` 和 `glob` 等工具底层使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。默认情况下,ripgrep 遵循 `.gitignore` 中的模式,这意味着 `.gitignore` 中列出的文件和目录将被排除在搜索和列表结果之外。 --- diff --git a/packages/web/src/content/docs/zh-tw/modes.mdx b/packages/web/src/content/docs/zh-tw/modes.mdx index c97aeb61b5..625b4d0219 100644 --- a/packages/web/src/content/docs/zh-tw/modes.mdx +++ b/packages/web/src/content/docs/zh-tw/modes.mdx @@ -230,7 +230,6 @@ Markdown 檔案名稱即為模式名稱(例如,`review.md` 建立一個名 | `read` | 讀取檔案內容 | | `grep` | 搜尋檔案內容 | | `glob` | 按模式尋找檔案 | -| `list` | 列出目錄內容 | | `patch` | 對檔案套用補丁 | | `todowrite` | 管理待辦事項清單 | | `webfetch` | 擷取網頁內容 | diff --git a/packages/web/src/content/docs/zh-tw/permissions.mdx b/packages/web/src/content/docs/zh-tw/permissions.mdx index 05b522e9c7..bacd87c1ed 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -88,7 +88,7 @@ OpenCode 使用 `permission` 設定來決定某個操作是否應自動執行、 ### 外部目錄 -使用 `external_directory` 允許工具呼叫存取 OpenCode 啟動時工作目錄之外的路徑。這適用於任何接受路徑作為輸入的工具(例如 `read`、`edit`、`list`、`glob`、`grep` 以及許多 `bash` 指令)。 +使用 `external_directory` 允許工具呼叫存取 OpenCode 啟動時工作目錄之外的路徑。這適用於任何接受路徑作為輸入的工具(例如 `read`、`edit`、`glob`、`grep` 以及許多 `bash` 指令)。 主目錄展開(如 `~/...`)僅影響模式的書寫方式。它不會將外部路徑納入當前工作空間,因此工作目錄之外的路徑仍然必須透過 `external_directory` 來允許。 @@ -133,7 +133,6 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `edit` — 所有檔案修改(涵蓋 `edit`、`write`、`patch`、`multiedit`) - `glob` — 檔案萬用字元比對(比對萬用字元模式) - `grep` — 內容搜尋(比對正規表示式模式) -- `list` — 列出目錄中的檔案(比對目錄路徑) - `bash` — 執行 shell 指令(比對解析後的指令,如 `git status --porcelain`) - `task` — 啟動子代理(比對子代理類型) - `skill` — 載入技能(比對技能名稱) diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx index 80e27ea0cc..cba32793d4 100644 --- a/packages/web/src/content/docs/zh-tw/tools.mdx +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -149,22 +149,6 @@ description: 管理 LLM 可以使用的工具。 使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜尋檔案。回傳按修改時間排序的匹配檔案路徑。 ---- - -### list - -列出指定路徑下的檔案和目錄。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "list": "allow" - } -} -``` - -該工具用於列出目錄內容。它接受 glob 模式來過濾結果。 --- @@ -341,7 +325,7 @@ MCP(Model Context Protocol)伺服器允許您整合外部工具和服務, ## 內部機制 -在內部,`grep`、`glob` 和 `list` 等工具底層使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。預設情況下,ripgrep 遵循 `.gitignore` 中的模式,這意味著 `.gitignore` 中列出的檔案和目錄將被排除在搜尋和列表結果之外。 +在內部,`grep` 和 `glob` 等工具底層使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。預設情況下,ripgrep 遵循 `.gitignore` 中的模式,這意味著 `.gitignore` 中列出的檔案和目錄將被排除在搜尋和列表結果之外。 --- From 916131be19893b84f17902825a163a0b67274249 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 06:44:55 +0800 Subject: [PATCH 145/154] core: move plugin intialisation to config layer override (#22620) --- packages/opencode/src/effect/app-runtime.ts | 20 +++++++++++++++++++- packages/opencode/src/project/bootstrap.ts | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 257922dafe..668c89b60b 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,13 +47,31 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" +import * as Effect from "effect/Effect" + +// Adjusts the default Config layer to ensure that plugins are always initialised before +// any other layers read the current config +const ConfigWithPluginPriority = Layer.effect( + Config.Service, + Effect.gen(function* () { + const config = yield* Config.Service + const plugin = yield* Plugin.Service + + return { + ...config, + get: () => Effect.andThen(plugin.init(), config.get), + getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal), + getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState), + } + }), +).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer))) export const AppLayer = Layer.mergeAll( AppFileSystem.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, - Config.defaultLayer, + ConfigWithPluginPriority, Git.defaultLayer, Ripgrep.defaultLayer, FileTime.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a1f2a8cb02..0babdfe13b 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -15,7 +15,6 @@ import * as Effect from "effect/Effect" export const InstanceBootstrap = Effect.gen(function* () { Log.Default.info("bootstrapping", { directory: Instance.directory }) - yield* Plugin.Service.use((svc) => svc.init()) yield* Effect.all( [ LSP.Service, From 83e257b468d75f6361f9ce50d930f110dfd37365 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 15 Apr 2026 22:45:54 +0000 Subject: [PATCH 146/154] chore: generate --- packages/web/src/content/docs/ar/tools.mdx | 1 - packages/web/src/content/docs/bs/tools.mdx | 1 - packages/web/src/content/docs/da/tools.mdx | 1 - packages/web/src/content/docs/de/tools.mdx | 1 - packages/web/src/content/docs/es/tools.mdx | 1 - packages/web/src/content/docs/fr/tools.mdx | 1 - packages/web/src/content/docs/it/modes.mdx | 22 +++++++++---------- packages/web/src/content/docs/it/tools.mdx | 1 - packages/web/src/content/docs/ja/modes.mdx | 22 +++++++++---------- packages/web/src/content/docs/ja/tools.mdx | 1 - packages/web/src/content/docs/ko/tools.mdx | 1 - packages/web/src/content/docs/modes.mdx | 22 +++++++++---------- packages/web/src/content/docs/nb/tools.mdx | 1 - packages/web/src/content/docs/pl/tools.mdx | 1 - packages/web/src/content/docs/pt-br/tools.mdx | 1 - packages/web/src/content/docs/ru/modes.mdx | 22 +++++++++---------- packages/web/src/content/docs/ru/tools.mdx | 1 - packages/web/src/content/docs/th/modes.mdx | 22 +++++++++---------- packages/web/src/content/docs/th/tools.mdx | 1 - packages/web/src/content/docs/tr/tools.mdx | 1 - packages/web/src/content/docs/zh-cn/tools.mdx | 1 - packages/web/src/content/docs/zh-tw/tools.mdx | 1 - 22 files changed, 55 insertions(+), 72 deletions(-) diff --git a/packages/web/src/content/docs/ar/tools.mdx b/packages/web/src/content/docs/ar/tools.mdx index f1477a08c2..3f3c9ee068 100644 --- a/packages/web/src/content/docs/ar/tools.mdx +++ b/packages/web/src/content/docs/ar/tools.mdx @@ -149,7 +149,6 @@ description: إدارة الأدوات التي يمكن لـ LLM استخدام ابحث عن الملفات باستخدام أنماط glob مثل `**/*.js` أو `src/**/*.ts`. يعيد مسارات الملفات المطابقة مرتبة حسب وقت التعديل. - --- ### lsp (experimental) diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx index 6c4d546141..db04295fd2 100644 --- a/packages/web/src/content/docs/bs/tools.mdx +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -149,7 +149,6 @@ Pronalazi datoteke po obrascima. Trazi datoteke koristeci glob obrasce kao `**/*.js` ili `src/**/*.ts`. Vraca putanje sortirane po vremenu izmjene. - --- ### lsp (eksperimentalno) diff --git a/packages/web/src/content/docs/da/tools.mdx b/packages/web/src/content/docs/da/tools.mdx index 043aabed43..6f6f95c9c5 100644 --- a/packages/web/src/content/docs/da/tools.mdx +++ b/packages/web/src/content/docs/da/tools.mdx @@ -149,7 +149,6 @@ Finn filer etter mønstermatching. Søk etter filer ved at bruge glob-mønstre som `**/*.js` eller `src/**/*.ts`. Returnerer samsvarende filbaner sortert etter endringstid. - --- ### lsp (experimental) diff --git a/packages/web/src/content/docs/de/tools.mdx b/packages/web/src/content/docs/de/tools.mdx index 98f5c708c2..6012148c6a 100644 --- a/packages/web/src/content/docs/de/tools.mdx +++ b/packages/web/src/content/docs/de/tools.mdx @@ -156,7 +156,6 @@ Findet Dateien per Musterabgleich. Sucht nach Dateien mit Glob-Mustern wie `**/*.js` oder `src/**/*.ts`. Gibt passende Dateipfade sortiert nach Aenderungsdatum zurueck. - --- ### lsp (experimentell) diff --git a/packages/web/src/content/docs/es/tools.mdx b/packages/web/src/content/docs/es/tools.mdx index 7d594a1c9f..83d61f5325 100644 --- a/packages/web/src/content/docs/es/tools.mdx +++ b/packages/web/src/content/docs/es/tools.mdx @@ -149,7 +149,6 @@ Encuentre archivos por coincidencia de patrones. Busque archivos usando patrones globales como `**/*.js` o `src/**/*.ts`. Devuelve rutas de archivos coincidentes ordenadas por hora de modificación. - --- ### lsp (experimental) diff --git a/packages/web/src/content/docs/fr/tools.mdx b/packages/web/src/content/docs/fr/tools.mdx index 483a953443..4f3f180469 100644 --- a/packages/web/src/content/docs/fr/tools.mdx +++ b/packages/web/src/content/docs/fr/tools.mdx @@ -149,7 +149,6 @@ Recherchez des fichiers par correspondance de modèles. Recherchez des fichiers à l'aide de modèles globaux tels que `**/*.js` ou `src/**/*.ts`. Renvoie les chemins de fichiers correspondants triés par heure de modification. - --- ### lsp (expérimental) diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx index b72b388fb0..052808f7ac 100644 --- a/packages/web/src/content/docs/it/modes.mdx +++ b/packages/web/src/content/docs/it/modes.mdx @@ -224,17 +224,17 @@ Se non specifichi gli strumenti, tutti gli strumenti sono abilitati per impostaz Ecco tutti gli strumenti che possono essere controllati tramite la configurazione della modalita. -| Strumento | Descrizione | -| ----------- | --------------------------------- | -| `bash` | Esegue comandi shell | -| `edit` | Modifica file esistenti | -| `write` | Crea nuovi file | -| `read` | Legge contenuti dei file | -| `grep` | Cerca nei contenuti dei file | -| `glob` | Trova file per pattern | -| `patch` | Applica patch ai file | -| `todowrite` | Gestisce liste todo | -| `webfetch` | Recupera contenuti web | +| Strumento | Descrizione | +| ----------- | ---------------------------- | +| `bash` | Esegue comandi shell | +| `edit` | Modifica file esistenti | +| `write` | Crea nuovi file | +| `read` | Legge contenuti dei file | +| `grep` | Cerca nei contenuti dei file | +| `glob` | Trova file per pattern | +| `patch` | Applica patch ai file | +| `todowrite` | Gestisce liste todo | +| `webfetch` | Recupera contenuti web | --- diff --git a/packages/web/src/content/docs/it/tools.mdx b/packages/web/src/content/docs/it/tools.mdx index 0bf00ffc6f..c1e69f8beb 100644 --- a/packages/web/src/content/docs/it/tools.mdx +++ b/packages/web/src/content/docs/it/tools.mdx @@ -149,7 +149,6 @@ Trova file tramite pattern matching. Cerca file usando pattern glob come `**/*.js` o `src/**/*.ts`. Restituisce i percorsi corrispondenti ordinati per data di modifica. - --- ### lsp (experimental) diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx index 623c19552d..8ebe7f5e68 100644 --- a/packages/web/src/content/docs/ja/modes.mdx +++ b/packages/web/src/content/docs/ja/modes.mdx @@ -223,17 +223,17 @@ Markdown ファイル名はモード名になります (例: `review.md` は `re ここでは、モード設定を通じて制御できるすべてのツールを示します。 -| ツール | 説明 | -| ----------- | ------------------------------ | -| `bash` | シェルコマンドを実行する | -| `edit` | 既存のファイルを変更する | -| `write` | 新しいファイルを作成する | -| `read` | ファイルの内容を読み取る | -| `grep` | ファイルの内容を検索 | -| `glob` | パターンでファイルを検索 | -| `patch` | ファイルにパッチを適用する | -| `todowrite` | ToDo リストを管理する | -| `webfetch` | Web コンテンツを取得する | +| ツール | 説明 | +| ----------- | -------------------------- | +| `bash` | シェルコマンドを実行する | +| `edit` | 既存のファイルを変更する | +| `write` | 新しいファイルを作成する | +| `read` | ファイルの内容を読み取る | +| `grep` | ファイルの内容を検索 | +| `glob` | パターンでファイルを検索 | +| `patch` | ファイルにパッチを適用する | +| `todowrite` | ToDo リストを管理する | +| `webfetch` | Web コンテンツを取得する | --- diff --git a/packages/web/src/content/docs/ja/tools.mdx b/packages/web/src/content/docs/ja/tools.mdx index ae409aa7db..3945063936 100644 --- a/packages/web/src/content/docs/ja/tools.mdx +++ b/packages/web/src/content/docs/ja/tools.mdx @@ -149,7 +149,6 @@ OpenCode で利用可能なすべての組み込みツールを次に示しま `**/*.js` や `src/**/*.ts` などの glob パターンを使用してファイルを検索します。一致するファイルパスを変更時間順に並べて返します。 - --- ### lsp (実験的) diff --git a/packages/web/src/content/docs/ko/tools.mdx b/packages/web/src/content/docs/ko/tools.mdx index b98578b58e..49bea93cb2 100644 --- a/packages/web/src/content/docs/ko/tools.mdx +++ b/packages/web/src/content/docs/ko/tools.mdx @@ -149,7 +149,6 @@ Codebase에서 빠른 콘텐츠 검색. 전체 regex 문법 및 파일 패턴 `**/*.js` 또는 `src/**/*.ts`와 같은 glob 패턴을 사용하여 파일 검색. 수정 시간에 의해 정렬 된 파일 경로 반환. - --- ### lsp (실험적) diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index 8ce2c0d13e..b8ea697399 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -225,17 +225,17 @@ If no tools are specified, all tools are enabled by default. Here are all the tools can be controlled through the mode config. -| Tool | Description | -| ----------- | ----------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | +| Tool | Description | +| ----------- | ---------------------- | +| `bash` | Execute shell commands | +| `edit` | Modify existing files | +| `write` | Create new files | +| `read` | Read file contents | +| `grep` | Search file contents | +| `glob` | Find files by pattern | +| `patch` | Apply patches to files | +| `todowrite` | Manage todo lists | +| `webfetch` | Fetch web content | --- diff --git a/packages/web/src/content/docs/nb/tools.mdx b/packages/web/src/content/docs/nb/tools.mdx index 2a67378e0a..8c871f11c9 100644 --- a/packages/web/src/content/docs/nb/tools.mdx +++ b/packages/web/src/content/docs/nb/tools.mdx @@ -149,7 +149,6 @@ Finn filer etter mønstermatching. Søk etter filer ved å bruke glob-mønstre som `**/*.js` eller `src/**/*.ts`. Returnerer samsvarende filbaner sortert etter endringstid. - --- ### lsp (eksperimentell) diff --git a/packages/web/src/content/docs/pl/tools.mdx b/packages/web/src/content/docs/pl/tools.mdx index 0690fb18c3..180e043cd5 100644 --- a/packages/web/src/content/docs/pl/tools.mdx +++ b/packages/web/src/content/docs/pl/tools.mdx @@ -149,7 +149,6 @@ Znajduj pliki na podstawie wzorców. Szukaj plików przy użyciu wzorców glob, takich jak `**/*.js` lub `src/**/*.ts`. Zwraca pasujące ścieżki plików posortowane według czasu modyfikacji. - --- ### LSP (eksperymentalne) diff --git a/packages/web/src/content/docs/pt-br/tools.mdx b/packages/web/src/content/docs/pt-br/tools.mdx index 43099ab52c..4c7b371971 100644 --- a/packages/web/src/content/docs/pt-br/tools.mdx +++ b/packages/web/src/content/docs/pt-br/tools.mdx @@ -149,7 +149,6 @@ Encontre arquivos por correspondência de padrões. Pesquise arquivos usando padrões glob como `**/*.js` ou `src/**/*.ts`. Retorna caminhos de arquivos correspondentes ordenados por tempo de modificação. - --- ### lsp (experimental) diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx index 6a4a74ceda..e63c91ace4 100644 --- a/packages/web/src/content/docs/ru/modes.mdx +++ b/packages/web/src/content/docs/ru/modes.mdx @@ -225,17 +225,17 @@ Provide constructive feedback without making direct changes. Вот всеми инструментами можно управлять через конфигурацию режима. -| Инструмент | Описание | -| ----------- | ----------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | +| Инструмент | Описание | +| ----------- | ---------------------- | +| `bash` | Execute shell commands | +| `edit` | Modify existing files | +| `write` | Create new files | +| `read` | Read file contents | +| `grep` | Search file contents | +| `glob` | Find files by pattern | +| `patch` | Apply patches to files | +| `todowrite` | Manage todo lists | +| `webfetch` | Fetch web content | --- diff --git a/packages/web/src/content/docs/ru/tools.mdx b/packages/web/src/content/docs/ru/tools.mdx index 8d4b5bf99e..35958e036c 100644 --- a/packages/web/src/content/docs/ru/tools.mdx +++ b/packages/web/src/content/docs/ru/tools.mdx @@ -149,7 +149,6 @@ description: Управляйте инструментами, которые м Ищите файлы, используя шаблоны glob, например `**/*.js` или `src/**/*.ts`. Возвращает соответствующие пути к файлам, отсортированные по времени изменения. - --- ### lsp (экспериментальный) diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx index 1569a5ad12..6cca309987 100644 --- a/packages/web/src/content/docs/th/modes.mdx +++ b/packages/web/src/content/docs/th/modes.mdx @@ -225,17 +225,17 @@ Provide constructive feedback without making direct changes. นี่คือเครื่องมือทั้งหมดที่สามารถควบคุมได้ผ่านการกำหนดค่าโหมด -| เครื่องมือ | คำอธิบาย | -| ----------- | --------------------------- | -| `bash` | ดำเนินการคำสั่ง shell | -| `edit` | แก้ไขไฟล์ที่มีอยู่ | -| `write` | สร้างไฟล์ใหม่ | -| `read` | อ่านเนื้อหาไฟล์ | -| `grep` | ค้นหาเนื้อหาไฟล์ | -| `glob` | ค้นหาไฟล์ตามรูปแบบ | -| `patch` | ใช้แพทช์กับไฟล์ | -| `todowrite` | จัดการรายการสิ่งที่ต้องทำ | -| `webfetch` | ดึงเนื้อหาเว็บ | +| เครื่องมือ | คำอธิบาย | +| ----------- | ------------------------- | +| `bash` | ดำเนินการคำสั่ง shell | +| `edit` | แก้ไขไฟล์ที่มีอยู่ | +| `write` | สร้างไฟล์ใหม่ | +| `read` | อ่านเนื้อหาไฟล์ | +| `grep` | ค้นหาเนื้อหาไฟล์ | +| `glob` | ค้นหาไฟล์ตามรูปแบบ | +| `patch` | ใช้แพทช์กับไฟล์ | +| `todowrite` | จัดการรายการสิ่งที่ต้องทำ | +| `webfetch` | ดึงเนื้อหาเว็บ | --- diff --git a/packages/web/src/content/docs/th/tools.mdx b/packages/web/src/content/docs/th/tools.mdx index 3c9b88c0a1..0ead638461 100644 --- a/packages/web/src/content/docs/th/tools.mdx +++ b/packages/web/src/content/docs/th/tools.mdx @@ -149,7 +149,6 @@ description: จัดการเครื่องมือที่ LLM ส ค้นหาไฟล์โดยใช้รูปแบบ glob เช่น `**/*.js` หรือ `src/**/*.ts` ส่งคืนเส้นทางไฟล์ที่ตรงกันโดยจัดเรียงตามเวลาแก้ไข - --- ### lsp (ขั้นทดลอง) diff --git a/packages/web/src/content/docs/tr/tools.mdx b/packages/web/src/content/docs/tr/tools.mdx index 633abb4a6e..2beb190094 100644 --- a/packages/web/src/content/docs/tr/tools.mdx +++ b/packages/web/src/content/docs/tr/tools.mdx @@ -149,7 +149,6 @@ Desen eşleştirme ile dosya bulur. `**/*.js` veya `src/**/*.ts` gibi glob desenleriyle dosya arar. Eşleşen dosya yollarını değişim zamanına göre sıralar. - --- ### lsp (deneysel) diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index c6d6d71940..4c60370590 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -149,7 +149,6 @@ description: 管理 LLM 可以使用的工具。 使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜索文件。返回按修改时间排序的匹配文件路径。 - --- ### lsp(实验性) diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx index cba32793d4..6ce68d9fb5 100644 --- a/packages/web/src/content/docs/zh-tw/tools.mdx +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -149,7 +149,6 @@ description: 管理 LLM 可以使用的工具。 使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜尋檔案。回傳按修改時間排序的匹配檔案路徑。 - --- ### lsp(實驗性) From e16589f8b535c216939d690d721cd1eefc3c1c2a Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:58:05 +0100 Subject: [PATCH 147/154] tweak(ui): session spacing (#20839) Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Brendan Allan --- packages/opencode/.opencode/package-lock.json | 31 ++++++++++++++ packages/ui/src/components/collapsible.css | 2 +- packages/ui/src/components/markdown.css | 30 +++++++------- packages/ui/src/components/message-part.css | 19 +++++---- packages/ui/src/components/session-turn.css | 5 ++- .../timeline-playground.stories.tsx | 41 ++++++++++++++++--- session.json | 0 7 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/.opencode/package-lock.json create mode 100644 session.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json new file mode 100644 index 0000000000..cd3c011efc --- /dev/null +++ b/packages/opencode/.opencode/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.2.6", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.2.6", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 608ca6e0eb..82c133f738 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -9,7 +9,7 @@ overflow: visible; &.tool-collapsible { - --tool-content-gap: 8px; + --tool-content-gap: 4px; gap: var(--tool-content-gap); } diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d..26c9efd475 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -6,7 +6,7 @@ color: var(--text-strong); font-family: var(--font-family-sans); font-size: var(--font-size-base); /* 14px */ - line-height: var(--line-height-x-large); + line-height: 160%; /* Spacing for flow */ > *:first-child { @@ -23,11 +23,11 @@ h4, h5, h6 { - font-size: var(--font-size-base); + font-size: 14px; color: var(--text-strong); font-weight: var(--font-weight-medium); - margin-top: 2rem; - margin-bottom: 0.75rem; + margin-top: 0px; + margin-bottom: 24px; line-height: var(--line-height-large); } @@ -40,7 +40,7 @@ /* Paragraphs */ p { - margin-bottom: 1rem; + margin-bottom: 12px; } /* Links */ @@ -58,10 +58,10 @@ /* Lists */ ul, ol { - margin-top: 0.5rem; - margin-bottom: 1rem; + margin-top: 8px; + margin-bottom: 12px; margin-left: 0; - padding-left: 1.5rem; + padding-left: 32px; list-style-position: outside; } @@ -75,7 +75,7 @@ } li { - margin-bottom: 0.5rem; + margin-bottom: 8px; } li > p:first-child { @@ -117,12 +117,12 @@ hr { border: none; height: 0; - margin: 2.5rem 0; + margin: 40px 0; } .shiki { font-size: 13px; - padding: 8px 12px; + padding: 12px; border-radius: 6px; border: 0.5px solid var(--border-weak-base); } @@ -201,8 +201,8 @@ } pre { - margin-top: 2rem; - margin-bottom: 2rem; + margin-top: 12px; + margin-bottom: 32px; overflow: auto; scrollbar-width: none; @@ -229,7 +229,7 @@ table { width: 100%; border-collapse: collapse; - margin: 1.5rem 0; + margin: 24px 0; font-size: var(--font-size-base); display: block; overflow-x: auto; @@ -239,7 +239,7 @@ td { /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ border-bottom: 1px solid var(--border-weaker-base); - padding: 0.75rem 0.5rem; + padding: 12px; text-align: left; vertical-align: top; } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d9893503fb..c84a368922 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -283,9 +283,9 @@ line-height: var(--line-height-normal); [data-component="markdown"] { - margin-top: 24px; + margin-top: 16px; font-style: normal; - font-size: var(--font-size-base); + font-size: 13px; color: var(--text-weak); strong, @@ -556,9 +556,12 @@ [data-component="exa-tool-output"] { width: 100%; - padding-top: 8px; display: flex; flex-direction: column; + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-large); + color: var(--text-base); } [data-slot="basic-tool-tool-subtitle"].exa-tool-query { @@ -578,6 +581,8 @@ [data-slot="exa-tool-link"] { display: block; max-width: 100%; + font: inherit; + line-height: inherit; color: var(--text-interactive-base); text-decoration: underline; text-underline-offset: 2px; @@ -636,13 +641,13 @@ } [data-component="context-tool-group-list"] { - padding-top: 6px; + padding-top: 0; padding-right: 0; - padding-bottom: 4px; - padding-left: 13px; + padding-bottom: 0; + padding-left: 12px; display: flex; flex-direction: column; - gap: 8px; + gap: 4px; [data-slot="context-tool-group-item"] { min-width: 0; diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index b01343a01d..54076f3f89 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -26,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 18px; + gap: 0px; overflow-anchor: none; } @@ -47,6 +47,7 @@ display: flex; align-items: center; gap: 8px; + margin-top: 12px; width: 100%; min-width: 0; color: var(--text-weak); @@ -226,5 +227,5 @@ } [data-slot="session-turn-list"] { - gap: 48px; + gap: 24px; } diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index e79e97a3ab..98cdf85001 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -568,6 +568,7 @@ const MD = "markdown.css" const MP = "message-part.css" const ST = "session-turn.css" const CL = "collapsible.css" +const BT = "basic-tool.css" /** * Source mapping for a CSS control. @@ -607,10 +608,10 @@ const CSS_CONTROLS: CSSControl[] = [ // --- Timeline spacing --- { key: "turn-gap", - label: "Turn gap", + label: "Above user messages", group: "Timeline Spacing", type: "range", - initial: "48", + initial: "32", selector: '[data-slot="session-turn-list"]', property: "gap", min: "0", @@ -621,10 +622,10 @@ const CSS_CONTROLS: CSSControl[] = [ }, { key: "container-gap", - label: "Container gap", + label: "Below user messages", group: "Timeline Spacing", type: "range", - initial: "18", + initial: "0", selector: '[data-slot="session-turn-message-container"]', property: "gap", min: "0", @@ -1040,12 +1041,40 @@ const CSS_CONTROLS: CSSControl[] = [ }, // --- Tool parts --- + { + key: "tool-subtitle-font-size", + label: "Subtitle font size", + group: "Tool Parts", + type: "range", + initial: "14", + selector: '[data-slot="basic-tool-tool-subtitle"]', + property: "font-size", + min: "10", + max: "22", + step: "1", + unit: "px", + source: { file: BT, anchor: '[data-slot="basic-tool-tool-subtitle"]', prop: "font-size", format: px }, + }, + { + key: "exa-output-font-size", + label: "Search output font size", + group: "Tool Parts", + type: "range", + initial: "14", + selector: '[data-component="exa-tool-output"]', + property: "font-size", + min: "10", + max: "22", + step: "1", + unit: "px", + source: { file: MP, anchor: '[data-component="exa-tool-output"]', prop: "font-size", format: px }, + }, { key: "tool-content-gap", label: "Trigger/content gap", group: "Tool Parts", type: "range", - initial: "8", + initial: "4", selector: '[data-component="collapsible"].tool-collapsible', property: "--tool-content-gap", min: "0", @@ -1059,7 +1088,7 @@ const CSS_CONTROLS: CSSControl[] = [ label: "Explored tool gap", group: "Explored Group", type: "range", - initial: "14", + initial: "4", selector: '[data-component="context-tool-group-list"]', property: "gap", min: "0", diff --git a/session.json b/session.json new file mode 100644 index 0000000000..e69de29bb2 From 672ee28635f471c5fcdc7e77c518d4465678d786 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 19:20:25 -0400 Subject: [PATCH 148/154] fix(opencode): avoid org lookup during config startup (#22670) --- AGENTS.md | 26 -------------------------- packages/opencode/src/config/config.ts | 20 +++++++++++--------- 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b080ac4e2..a7895c831f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,36 +11,10 @@ - Keep things in one function unless composable or reusable - Avoid `try`/`catch` where possible - Avoid using the `any` type -- Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream -### Naming - -Prefer single word names for variables and functions. Only use multiple words if necessary. - -### Naming Enforcement (Read This) - -THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE. - -- Use single word names by default for new locals, params, and helper functions. -- Multi-word names are allowed only when a single word would be unclear or ambiguous. -- Do not introduce new camelCase compounds when a short single-word alternative is clear. -- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible. -- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`. -- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`. - -```ts -// Good -const foo = 1 -function journal(dir: string) {} - -// Bad -const fooBar = 1 -function prepareJournal(dir: string) {} -``` - Reduce total variable count by inlining when a value is only used once. ```ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 915e604e90..6aee4e1dc8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1277,7 +1277,7 @@ export namespace Config { return yield* cachedGlobal }) - const install = Effect.fnUntraced(function* (dir: string) { + const install = Effect.fn("Config.install")(function* (dir: string) { const pkg = path.join(dir, "package.json") const gitignore = path.join(dir, ".gitignore") const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") @@ -1345,7 +1345,7 @@ export namespace Config { ) }) - const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { + const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) { const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} @@ -1468,13 +1468,16 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - const activeOrg = Option.getOrUndefined( - yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + const activeAccount = Option.getOrUndefined( + yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), ) - if (activeOrg) { + if (activeAccount?.active_org_id) { + const accountID = activeAccount.id + const orgID = activeAccount.active_org_id + const url = activeAccount.url yield* Effect.gen(function* () { const [configOpt, tokenOpt] = yield* Effect.all( - [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)], + [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], { concurrency: 2 }, ) if (Option.isSome(tokenOpt)) { @@ -1482,10 +1485,8 @@ export namespace Config { yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) } - activeOrgName = activeOrg.org.name - if (Option.isSome(configOpt)) { - const source = `${activeOrg.account.url}/api/config` + const source = `${url}/api/config` const next = yield* loadConfig(JSON.stringify(configOpt.value), { dir: path.dirname(source), source, @@ -1496,6 +1497,7 @@ export namespace Config { yield* merge(source, next, "global") } }).pipe( + Effect.withSpan("Config.loadActiveOrgConfig"), Effect.catch((err) => { log.debug("failed to fetch remote account config", { error: err instanceof Error ? err.message : String(err), From 4dd0d1f67e6f600f629617e3b7c7881f4d4a1a6e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 19:36:30 -0400 Subject: [PATCH 149/154] refactor(opencode): use AppFileSystem path helpers (#22637) --- packages/opencode/src/config/tui.ts | 5 ++--- packages/opencode/src/file/time.ts | 9 ++++----- packages/opencode/src/project/instance.ts | 10 +++++----- packages/opencode/src/server/instance/middleware.ts | 4 ++-- packages/opencode/src/tool/edit.ts | 3 +-- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 87c39e700a..12bd7e0dac 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -10,7 +10,6 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -42,8 +41,8 @@ export namespace TuiConfig { export class Service extends Context.Service()("@opencode/TuiConfig") {} function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { - if (Filesystem.contains(ctx.directory, file)) return "local" - if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" + if (AppFileSystem.contains(ctx.directory, file)) return "local" + if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" return "global" } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 5537526730..853da3bd98 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -3,7 +3,6 @@ import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { Filesystem } from "@/util/filesystem" import { Log } from "../util/log" export namespace FileTime { @@ -62,7 +61,7 @@ export namespace FileTime { ) const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { - filepath = Filesystem.normalizePath(filepath) + filepath = AppFileSystem.normalizePath(filepath) const locks = (yield* InstanceState.get(state)).locks const lock = locks.get(filepath) if (lock) return lock @@ -73,21 +72,21 @@ export namespace FileTime { }) const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - file = Filesystem.normalizePath(file) + file = AppFileSystem.normalizePath(file) 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) { - file = Filesystem.normalizePath(file) + file = AppFileSystem.normalizePath(file) 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 - filepath = Filesystem.normalizePath(filepath) + filepath = AppFileSystem.normalizePath(filepath) const reads = (yield* InstanceState.get(state)).reads const time = reads.get(sessionID)?.get(filepath) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 51ae669dc8..2a20ecac97 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,7 +1,7 @@ import { GlobalBus } from "@/bus/global" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" -import { Filesystem } from "@/util/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" import { LocalContext } from "../util/local-context" @@ -56,7 +56,7 @@ function track(directory: string, next: Promise) { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) + const directory = AppFileSystem.resolve(input.directory) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) @@ -93,11 +93,11 @@ export const Instance = { */ containsPath(filepath: string, ctx?: InstanceContext) { const instance = ctx ?? Instance - if (Filesystem.contains(instance.directory, filepath)) return true + if (AppFileSystem.contains(instance.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (Instance.worktree === "/") return false - return Filesystem.contains(instance.worktree, filepath) + return AppFileSystem.contains(instance.worktree, filepath) }, /** * Captures the current instance ALS context and returns a wrapper that @@ -117,7 +117,7 @@ export const Instance = { return context.provide(ctx, fn) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) + const directory = AppFileSystem.resolve(input.directory) Log.Default.info("reloading instance", { directory }) await disposeInstance(directory) cache.delete(directory) diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 824c265efe..549fb38d5d 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -4,7 +4,6 @@ import { getAdaptor } from "@/control-plane/adaptors" import { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { ServerProxy } from "../proxy" -import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { Session } from "@/session" @@ -12,6 +11,7 @@ import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { AppRuntime } from "@/effect/app-runtime" import { Log } from "@/util/log" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } @@ -53,7 +53,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return async (c, next) => { const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( + const directory = AppFileSystem.resolve( (() => { try { return decodeURIComponent(raw) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index bc8478e39f..5c82463945 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -15,7 +15,6 @@ import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { Format } from "../format" import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" @@ -169,7 +168,7 @@ export const EditTool = Tool.define( let output = "Edit applied successfully." yield* lsp.touchFile(filePath, true) const diagnostics = yield* lsp.diagnostics() - const normalizedFilePath = Filesystem.normalizePath(filePath) + const normalizedFilePath = AppFileSystem.normalizePath(filePath) const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}` From a554fad2327c68b2dc562a19e62a96415028b6d8 Mon Sep 17 00:00:00 2001 From: Carlo Wood Date: Thu, 16 Apr 2026 01:41:35 +0200 Subject: [PATCH 150/154] fix(tui): Don't overwrite the agent that was specified on the command line (#20554) --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5a3e1d451d..d0f5b481cb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -37,6 +37,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { useArgs } from "@tui/context/args" export type PromptProps = { sessionID?: string @@ -81,6 +82,7 @@ export function Prompt(props: PromptProps) { const keybind = useKeybind() const local = useLocal() + const args = useArgs() const sdk = useSDK() const route = useRoute() const sync = useSync() @@ -202,7 +204,8 @@ export function Prompt(props: PromptProps) { // Only set agent if it's a primary agent (not a subagent) const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { - local.agent.set(msg.agent) + // Keep command line --agent if specified. + if (!args.agent) local.agent.set(msg.agent) if (msg.model) { local.model.set(msg.model) local.model.variant.set(msg.model.variant) From 3d6f90cb536ec30ff5091e1cbe3b1e619a93e1b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 20:45:19 -0400 Subject: [PATCH 151/154] feat: add oxlint with correctness defaults (#22682) --- .oxlintrc.json | 10 +++++ bun.lock | 41 +++++++++++++++++++ package.json | 2 + packages/app/src/components/file-tree.tsx | 2 +- packages/app/src/components/terminal.tsx | 2 +- .../src/context/global-sync/child-store.ts | 4 +- packages/app/src/context/layout.tsx | 4 +- .../app/src/routes/zen/util/handler.ts | 2 +- packages/console/core/src/key.ts | 8 +--- packages/desktop-electron/src/main/apps.ts | 2 +- .../desktop-electron/src/main/shell-env.ts | 2 +- packages/opencode/script/build.ts | 4 +- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 4 +- packages/opencode/src/config/tui.ts | 2 +- packages/opencode/src/lsp/launch.ts | 2 +- packages/opencode/src/plugin/cloudflare.ts | 6 +-- packages/opencode/src/plugin/meta.ts | 2 +- packages/opencode/src/provider/provider.ts | 4 +- .../opencode/src/server/instance/session.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- .../opencode/test/cli/tui/theme-store.test.ts | 2 +- .../test/plugin/loader-shared.test.ts | 10 ++--- .../test/plugin/workspace-adaptor.test.ts | 2 +- packages/shared/src/util/path.ts | 8 ++-- packages/ui/src/components/accordion.tsx | 10 ++--- packages/ui/src/components/app-icon.tsx | 2 +- packages/ui/src/components/avatar.tsx | 2 +- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/card.tsx | 8 ++-- packages/ui/src/components/collapsible.tsx | 2 +- packages/ui/src/components/context-menu.tsx | 32 +++++++-------- packages/ui/src/components/dialog.tsx | 2 +- packages/ui/src/components/dock-surface.tsx | 6 +-- packages/ui/src/components/dropdown-menu.tsx | 32 +++++++-------- packages/ui/src/components/file-icon.tsx | 2 +- packages/ui/src/components/file-ssr.tsx | 4 +- packages/ui/src/components/file.tsx | 2 +- packages/ui/src/components/hover-card.tsx | 2 +- packages/ui/src/components/icon-button.tsx | 2 +- packages/ui/src/components/icon.tsx | 2 +- packages/ui/src/components/keybind.tsx | 2 +- packages/ui/src/components/markdown.tsx | 4 +- packages/ui/src/components/popover.tsx | 2 +- .../ui/src/components/progress-circle.tsx | 2 +- packages/ui/src/components/progress.tsx | 2 +- packages/ui/src/components/provider-icon.tsx | 2 +- packages/ui/src/components/radio-group.tsx | 2 +- packages/ui/src/components/resize-handle.tsx | 2 +- packages/ui/src/components/select.tsx | 6 +-- packages/ui/src/components/session-turn.tsx | 2 +- packages/ui/src/components/spinner.tsx | 2 +- .../components/sticky-accordion-header.tsx | 2 +- packages/ui/src/components/tabs.tsx | 8 ++-- packages/ui/src/components/tag.tsx | 2 +- packages/ui/src/components/toast.tsx | 2 +- 57 files changed, 165 insertions(+), 122 deletions(-) create mode 100644 .oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..0875f38326 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", + "rules": { + // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield + "require-yield": "off", + // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime + "no-unassigned-vars": "off" + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] +} diff --git a/bun.lock b/bun.lock index aeab042cf3..48243e652e 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "oxlint": "1.60.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", @@ -1693,6 +1694,44 @@ "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="], + "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="], "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="], @@ -4073,6 +4112,8 @@ "oxc-transform": ["oxc-transform@0.96.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm64": "0.96.0", "@oxc-transform/binding-darwin-arm64": "0.96.0", "@oxc-transform/binding-darwin-x64": "0.96.0", "@oxc-transform/binding-freebsd-x64": "0.96.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.96.0", "@oxc-transform/binding-linux-arm64-gnu": "0.96.0", "@oxc-transform/binding-linux-arm64-musl": "0.96.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.96.0", "@oxc-transform/binding-linux-s390x-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-musl": "0.96.0", "@oxc-transform/binding-wasm32-wasi": "0.96.0", "@oxc-transform/binding-win32-arm64-msvc": "0.96.0", "@oxc-transform/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ=="], + "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="], diff --git a/package.json b/package.json index abe1b5d362..8c5ae91955 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", + "lint": "oxlint", "typecheck": "bun turbo typecheck", "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", @@ -85,6 +86,7 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "oxlint": "1.60.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 930832fb65..8fbecf6712 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -149,7 +149,7 @@ const FileTreeNode = ( classList={{ "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, "bg-surface-base-active": local.node.path === local.active, - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, [local.nodeClass ?? ""]: !!local.nodeClass, }} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 96a865b9e8..9b7ef83b28 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -634,7 +634,7 @@ export const Terminal = (props: TerminalProps) => { tabIndex={-1} style={{ "background-color": terminalColors().background }} classList={{ - ...(local.classList ?? {}), + ...local.classList, "select-text": true, "size-full px-6 py-3 font-mono relative overflow-hidden": true, [local.class ?? ""]: !!local.class, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f89..3fe67e4fbe 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -243,8 +243,8 @@ export function createChildStoreManager(input: { const cached = metaCache.get(directory) if (!cached) return const previous = store.projectMeta ?? {} - const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon - const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands + const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon + const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands const next = { ...previous, ...patch, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index bab3d39f38..87f11d2b64 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -344,7 +344,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return } - setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next })) prune(keep) }, }) @@ -399,7 +399,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( local?.icon?.color !== undefined const base = { - ...(metadata ?? {}), + ...metadata, ...project, icon: { url: metadata?.icon?.url, diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 58df618094..358d8736c4 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -144,7 +144,7 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, - ...(providerInfo.payloadModifier ?? {}), + ...providerInfo.payloadModifier, ...Object.fromEntries( Object.entries(providerInfo.payloadMappings ?? {}) .map(([k, v]) => [k, input.request.headers.get(v)]) diff --git a/packages/console/core/src/key.ts b/packages/console/core/src/key.ts index 688f19b3d8..d1aae15240 100644 --- a/packages/console/core/src/key.ts +++ b/packages/console/core/src/key.ts @@ -24,11 +24,9 @@ export namespace Key { .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email"))) .where( and( - ...[ - eq(KeyTable.workspaceID, Actor.workspace()), + eq(KeyTable.workspaceID, Actor.workspace()), isNull(KeyTable.timeDeleted), ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), - ], ), ) .orderBy(sql`${KeyTable.name} DESC`), @@ -84,11 +82,9 @@ export namespace Key { }) .where( and( - ...[ - eq(KeyTable.id, input.id), + eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, Actor.workspace()), ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), - ], ), ), ) diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index 2b46037894..d21b6cc9e3 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -20,7 +20,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string try { if (path.startsWith("~")) { const suffix = path.slice(1) - const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"` + const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd]) return output.toString().trim() } diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts index 8453a5730d..f57677323c 100644 --- a/packages/desktop-electron/src/main/shell-env.ts +++ b/packages/desktop-electron/src/main/shell-env.ts @@ -82,7 +82,7 @@ export function loadShellEnv(shell: string) { export function mergeShellEnv(shell: Record | null, env: Record) { return { - ...(shell || {}), + ...shell, ...env, } } diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 6d1087f287..d2628974fa 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -211,9 +211,7 @@ for (const item of targets) { execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, - files: { - ...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}), - }, + files: (embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}), entrypoints: [ "./src/index.ts", parserWorker, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8857696b05..ba38c8efe3 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -368,7 +368,7 @@ export namespace Agent { )), { 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`, + 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, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index d2afbabfb0..06f9fdf1d5 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -40,12 +40,10 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } else if (plugin.auth.methods.length > 1) { const method = await prompts.select({ message: "Login method", - options: [ - ...plugin.auth.methods.map((x, index) => ({ + options: plugin.auth.methods.map((x, index) => ({ label: x.label, value: index.toString(), })), - ], }) if (prompts.isCancel(method)) throw new UI.CancelledError() index = parseInt(method) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 12bd7e0dac..e64b226c14 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -125,7 +125,7 @@ export namespace TuiConfig { } } - const keybinds = { ...(acc.result.keybinds ?? {}) } + const keybinds = { ...acc.result.keybinds } if (process.platform === "win32") { // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. keybinds.terminal_suspend = "none" diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index b7dca446f5..51a7c209b4 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -9,7 +9,7 @@ export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : [] const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts const proc = Process.spawn([cmd, ...args], { - ...(cfg ?? {}), + ...cfg, stdin: "pipe", stdout: "pipe", stderr: "pipe", diff --git a/packages/opencode/src/plugin/cloudflare.ts b/packages/opencode/src/plugin/cloudflare.ts index e20a488a36..267d1ed2f2 100644 --- a/packages/opencode/src/plugin/cloudflare.ts +++ b/packages/opencode/src/plugin/cloudflare.ts @@ -1,8 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise { - const prompts = [ - ...(!process.env.CLOUDFLARE_ACCOUNT_ID + const prompts = (!process.env.CLOUDFLARE_ACCOUNT_ID ? [ { type: "text" as const, @@ -11,8 +10,7 @@ export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise< placeholder: "e.g. 1234567890abcdef1234567890abcdef", }, ] - : []), - ] + : []) return { auth: { diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index f408954690..3f02f543ef 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -174,7 +174,7 @@ export namespace PluginMeta { const entry = store[id] if (!entry) return entry.themes = { - ...(entry.themes ?? {}), + ...entry.themes, [name]: theme, } await Filesystem.writeJson(file, store) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9ec5dfc6b5..c029e5c5c6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -551,13 +551,13 @@ export namespace Provider { const aiGatewayHeaders = { "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "anthropic-beta": "context-1m-2025-08-07", - ...(providerConfig?.options?.aiGatewayHeaders || {}), + ...providerConfig?.options?.aiGatewayHeaders, } const featureFlags = { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), + ...providerConfig?.options?.featureFlags, } return { diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 0bce3085e0..c606af8544 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -695,7 +695,7 @@ export const SessionRoutes = lazy(() => url.searchParams.set("limit", query.limit.toString()) url.searchParams.set("before", page.cursor) c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel=\"next\"`) + c.header("Link", `<${url.toString()}>; rel="next"`) c.header("X-Next-Cursor", page.cursor) } return c.json(page.items) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ffd074d3f8..f2a160e268 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -497,7 +497,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) const metadata = { - ...(result.metadata ?? {}), + ...result.metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3bb936944c..7a124dadae 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,7 +176,7 @@ function dynamic(text: string, ps: boolean) { } function prefix(text: string) { - const match = /[?*\[]/.exec(text) + const match = /[?*[]/.exec(text) if (!match) return text if (match.index === 0) return return text.slice(0, match.index) diff --git a/packages/opencode/test/cli/tui/theme-store.test.ts b/packages/opencode/test/cli/tui/theme-store.test.ts index 936e3e6f7c..9ebfc4320e 100644 --- a/packages/opencode/test/cli/tui/theme-store.test.ts +++ b/packages/opencode/test/cli/tui/theme-store.test.ts @@ -41,7 +41,7 @@ test("hasTheme checks theme presence", () => { test("resolveTheme rejects circular color refs", () => { const item = structuredClone(DEFAULT_THEMES.opencode) item.defs = { - ...(item.defs ?? {}), + ...item.defs, one: "two", two: "one", } diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 5f1e2b1686..4265e83c55 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -48,7 +48,7 @@ describe("plugin.loader.shared", () => { file, [ "export default async () => {", - ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + ` await Bun.write(${JSON.stringify(mark)}, "called")`, " return {}", "}", "", @@ -78,8 +78,8 @@ describe("plugin.loader.shared", () => { file, [ "const run = async () => {", - ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`, - ` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`, + ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`, + ` await Bun.write(${JSON.stringify(mark)}, text + "1")`, " return {}", "}", "export default run", @@ -715,7 +715,7 @@ describe("plugin.loader.shared", () => { "const plugin = {", ' id: "demo.object",', " server: async () => {", - ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + ` await Bun.write(${JSON.stringify(mark)}, "called")`, " return {}", " },", "}", @@ -833,7 +833,7 @@ export default { "export default {", ' id: "demo.pure",', " server: async () => {", - ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + ` await Bun.write(${JSON.stringify(mark)}, "called")`, " return {}", " },", "}", diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index a5f56df5e9..669a822a2f 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -39,7 +39,7 @@ describe("plugin.workspace", () => { ' name: "plug",', ' description: "plugin workspace adaptor",', " configure(input) {", - ` return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`, + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, " },", " async create(input) {", ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, diff --git a/packages/shared/src/util/path.ts b/packages/shared/src/util/path.ts index bb191f5120..b87316358f 100644 --- a/packages/shared/src/util/path.ts +++ b/packages/shared/src/util/path.ts @@ -1,14 +1,14 @@ export function getFilename(path: string | undefined) { if (!path) return "" - const trimmed = path.replace(/[\/\\]+$/, "") - const parts = trimmed.split(/[\/\\]/) + const trimmed = path.replace(/[/\\]+$/, "") + const parts = trimmed.split(/[/\\]/) return parts[parts.length - 1] ?? "" } export function getDirectory(path: string | undefined) { if (!path) return "" - const trimmed = path.replace(/[\/\\]+$/, "") - const parts = trimmed.split(/[\/\\]/) + const trimmed = path.replace(/[/\\]+$/, "") + const parts = trimmed.split(/[/\\]/) return parts.slice(0, parts.length - 1).join("/") + "/" } diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx index 535d38e3d0..3179b8a153 100644 --- a/packages/ui/src/components/accordion.tsx +++ b/packages/ui/src/components/accordion.tsx @@ -15,7 +15,7 @@ function AccordionRoot(props: AccordionProps) { {...rest} data-component="accordion" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} /> @@ -29,7 +29,7 @@ function AccordionItem(props: AccordionItemProps) { {...rest} data-slot="accordion-item" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} /> @@ -43,7 +43,7 @@ function AccordionHeader(props: ParentProps) { {...rest} data-slot="accordion-header" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -59,7 +59,7 @@ function AccordionTrigger(props: ParentProps) { {...rest} data-slot="accordion-trigger" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -75,7 +75,7 @@ function AccordionContent(props: ParentProps) { {...rest} data-slot="accordion-content" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx index f8b587ff26..541dfc5708 100644 --- a/packages/ui/src/components/app-icon.tsx +++ b/packages/ui/src/components/app-icon.tsx @@ -77,7 +77,7 @@ export const AppIcon: Component = (props) => { alt={local.alt ?? ""} draggable={local.draggable ?? false} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} /> diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index c1617b265c..035c2d3041 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -38,7 +38,7 @@ export function Avatar(props: AvatarProps) { data-size={split.size || "normal"} data-has-image={src ? "" : undefined} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} style={{ diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 7f974b2f76..d1652145f5 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -20,7 +20,7 @@ export function Button(props: ButtonProps) { data-variant={split.variant || "secondary"} data-icon={split.icon} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx index 7a1bd5e45b..320aba718c 100644 --- a/packages/ui/src/components/card.tsx +++ b/packages/ui/src/components/card.tsx @@ -53,7 +53,7 @@ export function Card(props: CardProps) { data-variant={variant()} style={mix(split.style, accent())} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -76,7 +76,7 @@ export function CardTitle(props: CardTitleProps) { {...rest} data-slot="card-title" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -97,7 +97,7 @@ export function CardDescription(props: ComponentProps<"div">) { {...rest} data-slot="card-description" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -113,7 +113,7 @@ export function CardActions(props: ComponentProps<"div">) { {...rest} data-slot="card-actions" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx index 8b5cd825ce..b2a6032646 100644 --- a/packages/ui/src/components/collapsible.tsx +++ b/packages/ui/src/components/collapsible.tsx @@ -15,7 +15,7 @@ function CollapsibleRoot(props: CollapsibleProps) { data-component="collapsible" data-variant={local.variant || "normal"} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} {...others} diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx index afdaff7b80..f4566a17a9 100644 --- a/packages/ui/src/components/context-menu.tsx +++ b/packages/ui/src/components/context-menu.tsx @@ -33,7 +33,7 @@ function ContextMenuTrigger(props: ParentProps) { {...rest} data-slot="context-menu-trigger" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -49,7 +49,7 @@ function ContextMenuIcon(props: ParentProps) { {...rest} data-slot="context-menu-icon" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -69,7 +69,7 @@ function ContextMenuContent(props: ParentProps) { {...rest} data-component="context-menu-content" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -85,7 +85,7 @@ function ContextMenuArrow(props: ContextMenuArrowProps) { {...rest} data-slot="context-menu-arrow" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} /> @@ -99,7 +99,7 @@ function ContextMenuSeparator(props: ContextMenuSeparatorProps) { {...rest} data-slot="context-menu-separator" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} /> @@ -113,7 +113,7 @@ function ContextMenuGroup(props: ParentProps) { {...rest} data-slot="context-menu-group" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -129,7 +129,7 @@ function ContextMenuGroupLabel(props: ParentProps) { {...rest} data-slot="context-menu-group-label" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -145,7 +145,7 @@ function ContextMenuItem(props: ParentProps) { {...rest} data-slot="context-menu-item" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -161,7 +161,7 @@ function ContextMenuItemLabel(props: ParentProps) { {...rest} data-slot="context-menu-item-label" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -177,7 +177,7 @@ function ContextMenuItemDescription(props: ParentProps @@ -193,7 +193,7 @@ function ContextMenuItemIndicator(props: ParentProps @@ -209,7 +209,7 @@ function ContextMenuRadioGroup(props: ParentProps) { {...rest} data-slot="context-menu-radio-group" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -225,7 +225,7 @@ function ContextMenuRadioItem(props: ParentProps) { {...rest} data-slot="context-menu-radio-item" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -241,7 +241,7 @@ function ContextMenuCheckboxItem(props: ParentProps @@ -261,7 +261,7 @@ function ContextMenuSubTrigger(props: ParentProps) { {...rest} data-slot="context-menu-sub-trigger" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -277,7 +277,7 @@ function ContextMenuSubContent(props: ParentProps) { {...rest} data-component="context-menu-sub-content" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index ce7704f37e..981e3f45d7 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -28,7 +28,7 @@ export function Dialog(props: DialogProps) { data-slot="dialog-content" data-no-header={!props.title && !props.action ? "" : undefined} classList={{ - ...(props.classList ?? {}), + ...props.classList, [props.class ?? ""]: !!props.class, }} onOpenAutoFocus={(e) => { diff --git a/packages/ui/src/components/dock-surface.tsx b/packages/ui/src/components/dock-surface.tsx index 1c4af2ed5e..06cf2a5eba 100644 --- a/packages/ui/src/components/dock-surface.tsx +++ b/packages/ui/src/components/dock-surface.tsx @@ -11,7 +11,7 @@ export function DockShell(props: ComponentProps<"div">) { {...rest} data-dock-surface="shell" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -27,7 +27,7 @@ export function DockShellForm(props: ComponentProps<"form">) { {...rest} data-dock-surface="shell" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > @@ -44,7 +44,7 @@ export function DockTray(props: DockTrayProps) { data-dock-surface="tray" data-dock-attach={split.attach || "none"} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx index efb2b45cae..259cb791ab 100644 --- a/packages/ui/src/components/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -33,7 +33,7 @@ function DropdownMenuTrigger(props: ParentProps) { {...rest} data-slot="dropdown-menu-trigger" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -49,7 +49,7 @@ function DropdownMenuIcon(props: ParentProps) { {...rest} data-slot="dropdown-menu-icon" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -69,7 +69,7 @@ function DropdownMenuContent(props: ParentProps) { {...rest} data-component="dropdown-menu-content" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -85,7 +85,7 @@ function DropdownMenuArrow(props: DropdownMenuArrowProps) { {...rest} data-slot="dropdown-menu-arrow" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} /> @@ -99,7 +99,7 @@ function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) { {...rest} data-slot="dropdown-menu-separator" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} /> @@ -113,7 +113,7 @@ function DropdownMenuGroup(props: ParentProps) { {...rest} data-slot="dropdown-menu-group" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -129,7 +129,7 @@ function DropdownMenuGroupLabel(props: ParentProps) {...rest} data-slot="dropdown-menu-group-label" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -145,7 +145,7 @@ function DropdownMenuItem(props: ParentProps) { {...rest} data-slot="dropdown-menu-item" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -161,7 +161,7 @@ function DropdownMenuItemLabel(props: ParentProps) { {...rest} data-slot="dropdown-menu-item-label" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -177,7 +177,7 @@ function DropdownMenuItemDescription(props: ParentProps @@ -193,7 +193,7 @@ function DropdownMenuItemIndicator(props: ParentProps @@ -209,7 +209,7 @@ function DropdownMenuRadioGroup(props: ParentProps) {...rest} data-slot="dropdown-menu-radio-group" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -225,7 +225,7 @@ function DropdownMenuRadioItem(props: ParentProps) { {...rest} data-slot="dropdown-menu-radio-item" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -241,7 +241,7 @@ function DropdownMenuCheckboxItem(props: ParentProps @@ -261,7 +261,7 @@ function DropdownMenuSubTrigger(props: ParentProps) {...rest} data-slot="dropdown-menu-sub-trigger" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -277,7 +277,7 @@ function DropdownMenuSubContent(props: ParentProps) {...rest} data-component="dropdown-menu-sub-content" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > diff --git a/packages/ui/src/components/file-icon.tsx b/packages/ui/src/components/file-icon.tsx index 133cb169c7..d66ee1c250 100644 --- a/packages/ui/src/components/file-icon.tsx +++ b/packages/ui/src/components/file-icon.tsx @@ -18,7 +18,7 @@ export const FileIcon: Component = (props) => { data-component="file-icon" {...rest} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index fed5c89315..ad05555bdf 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -99,7 +99,7 @@ function DiffSSRViewer(props: SSRDiffFileProps) { { ...createDefaultOptions(props.diffStyle), ...others, - ...(local.preloadedDiff.options ?? {}), + ...local.preloadedDiff.options, }, virtualizer, virtualMetrics, @@ -109,7 +109,7 @@ function DiffSSRViewer(props: SSRDiffFileProps) { { ...createDefaultOptions(props.diffStyle), ...others, - ...(local.preloadedDiff.options ?? {}), + ...local.preloadedDiff.options, }, workerPool, ) diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 51c2892737..fd902b2e08 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -655,7 +655,7 @@ function ViewerShell(props: { style={styleVariables} class="relative outline-none" classList={{ - ...(props.classList || {}), + ...props.classList, [props.class ?? ""]: !!props.class, }} ref={(el) => (props.viewer.wrapper = el)} diff --git a/packages/ui/src/components/hover-card.tsx b/packages/ui/src/components/hover-card.tsx index 8330375aa3..4e6647313f 100644 --- a/packages/ui/src/components/hover-card.tsx +++ b/packages/ui/src/components/hover-card.tsx @@ -20,7 +20,7 @@ export function HoverCard(props: HoverCardProps) { diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx index 89ab00fcd3..457283aa03 100644 --- a/packages/ui/src/components/icon-button.tsx +++ b/packages/ui/src/components/icon-button.tsx @@ -19,7 +19,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { data-size={split.size || "normal"} data-variant={split.variant || "secondary"} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e2eaf107a6..08726d0ff2 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -117,7 +117,7 @@ export function Icon(props: IconProps) { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index f3037da8bc..28653512e5 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -50,7 +50,7 @@ function escape(text: string) { .replace(/&/g, "&") .replace(//g, ">") - .replace(/\"/g, """) + .replace(/"/g, """) .replace(/'/g, "'") } @@ -338,7 +338,7 @@ export function Markdown(
(props: PopoverProps ref={(el: HTMLElement | undefined) => setState("contentRef", el)} data-component="popover-content" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} style={local.style} diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx index 02bd36bb71..992fb62e83 100644 --- a/packages/ui/src/components/progress-circle.tsx +++ b/packages/ui/src/components/progress-circle.tsx @@ -32,7 +32,7 @@ export function ProgressCircle(props: ProgressCircleProps) { fill="none" data-component="progress-circle" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx index bfe10a1d1e..7cbe5d6bcb 100644 --- a/packages/ui/src/components/progress.tsx +++ b/packages/ui/src/components/progress.tsx @@ -15,7 +15,7 @@ export function Progress(props: ProgressProps) { {...others} data-component="progress" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > diff --git a/packages/ui/src/components/provider-icon.tsx b/packages/ui/src/components/provider-icon.tsx index edfdd03571..7c0eb3d047 100644 --- a/packages/ui/src/components/provider-icon.tsx +++ b/packages/ui/src/components/provider-icon.tsx @@ -15,7 +15,7 @@ export const ProviderIcon: Component = (props) => { data-component="provider-icon" {...rest} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > diff --git a/packages/ui/src/components/radio-group.tsx b/packages/ui/src/components/radio-group.tsx index 544e852e47..9151a24b0f 100644 --- a/packages/ui/src/components/radio-group.tsx +++ b/packages/ui/src/components/radio-group.tsx @@ -56,7 +56,7 @@ export function RadioGroup(props: RadioGroupProps) { data-fill={local.fill ? "" : undefined} data-pad={local.pad ?? "normal"} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} value={local.current ? getValue(local.current) : undefined} diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index e2eed1bb7c..d7774a684b 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -73,7 +73,7 @@ export function ResizeHandle(props: ResizeHandleProps) { data-direction={local.direction} data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} onMouseDown={handleMouseDown} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 61804a9519..67becf2d9c 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -104,7 +104,7 @@ export function Select(props: SelectProps & Omit) {...itemProps} data-slot="select-select-item" classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} onPointerEnter={() => move(itemProps.item.rawValue)} @@ -141,7 +141,7 @@ export function Select(props: SelectProps & Omit) variant={props.variant} style={local.triggerStyle} classList={{ - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, }} > @@ -160,7 +160,7 @@ export function Select(props: SelectProps & Omit) diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 396504dd72..f46e9bfb30 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -27,7 +27,7 @@ function TabsRoot(props: TabsProps) { data-variant={split.variant || "normal"} data-orientation={split.orientation || "horizontal"} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} /> @@ -41,7 +41,7 @@ function TabsList(props: TabsListProps) { {...rest} data-slot="tabs-list" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} /> @@ -63,7 +63,7 @@ function TabsTrigger(props: ParentProps) { data-slot="tabs-trigger-wrapper" data-value={props.value} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} onMouseDown={(e) => { @@ -104,7 +104,7 @@ function TabsContent(props: ParentProps) { {...rest} data-slot="tabs-content" classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/tag.tsx b/packages/ui/src/components/tag.tsx index 428eedd0f3..c54e4d4747 100644 --- a/packages/ui/src/components/tag.tsx +++ b/packages/ui/src/components/tag.tsx @@ -12,7 +12,7 @@ export function Tag(props: TagProps) { data-component="tag" data-size={split.size || "normal"} classList={{ - ...(split.classList ?? {}), + ...split.classList, [split.class ?? ""]: !!split.class, }} > diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index e8062a2a8b..599cf2a9ea 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -30,7 +30,7 @@ function ToastRoot(props: ToastRootComponentProps) { Date: Thu, 16 Apr 2026 00:46:18 +0000 Subject: [PATCH 152/154] chore: generate --- packages/console/core/src/key.ts | 8 ++++---- packages/opencode/script/build.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 6 +++--- packages/opencode/src/plugin/cloudflare.ts | 20 ++++++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/console/core/src/key.ts b/packages/console/core/src/key.ts index d1aae15240..aef4298c90 100644 --- a/packages/console/core/src/key.ts +++ b/packages/console/core/src/key.ts @@ -25,8 +25,8 @@ export namespace Key { .where( and( eq(KeyTable.workspaceID, Actor.workspace()), - isNull(KeyTable.timeDeleted), - ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), + isNull(KeyTable.timeDeleted), + ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), ), ) .orderBy(sql`${KeyTable.name} DESC`), @@ -83,8 +83,8 @@ export namespace Key { .where( and( eq(KeyTable.id, input.id), - eq(KeyTable.workspaceID, Actor.workspace()), - ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), + eq(KeyTable.workspaceID, Actor.workspace()), + ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]), ), ), ) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index d2628974fa..5aa14d52cd 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -211,7 +211,7 @@ for (const item of targets) { execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, - files: (embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}), + files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, entrypoints: [ "./src/index.ts", parserWorker, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 06f9fdf1d5..6ab927e253 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -41,9 +41,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const method = await prompts.select({ message: "Login method", options: plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), + label: x.label, + value: index.toString(), + })), }) if (prompts.isCancel(method)) throw new UI.CancelledError() index = parseInt(method) diff --git a/packages/opencode/src/plugin/cloudflare.ts b/packages/opencode/src/plugin/cloudflare.ts index 267d1ed2f2..2ccf5168d8 100644 --- a/packages/opencode/src/plugin/cloudflare.ts +++ b/packages/opencode/src/plugin/cloudflare.ts @@ -1,16 +1,16 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise { - const prompts = (!process.env.CLOUDFLARE_ACCOUNT_ID - ? [ - { - type: "text" as const, - key: "accountId", - message: "Enter your Cloudflare Account ID", - placeholder: "e.g. 1234567890abcdef1234567890abcdef", - }, - ] - : []) + const prompts = !process.env.CLOUDFLARE_ACCOUNT_ID + ? [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ] + : [] return { auth: { From a147ad68e6aed8a6a3eeaf2ce1e56f73fab7fa31 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 20:55:14 -0400 Subject: [PATCH 153/154] feat(shared): add Effect-idiomatic file lock (EffectFlock) (#22681) --- packages/shared/src/util/effect-flock.ts | 278 +++++++++++++ .../test/fixture/effect-flock-worker.ts | 64 +++ .../shared/test/util/effect-flock.test.ts | 388 ++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 packages/shared/src/util/effect-flock.ts create mode 100644 packages/shared/test/fixture/effect-flock-worker.ts create mode 100644 packages/shared/test/util/effect-flock.test.ts diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts new file mode 100644 index 0000000000..d728c0ef15 --- /dev/null +++ b/packages/shared/src/util/effect-flock.ts @@ -0,0 +1,278 @@ +import path from "path" +import os from "os" +import { randomUUID } from "crypto" +import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect" +import type { FileSystem, Scope } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { AppFileSystem } from "../filesystem" +import { Global } from "../global" +import { Hash } from "./hash" + +export namespace EffectFlock { + // --------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------- + + export class LockTimeoutError extends Schema.TaggedErrorClass()("LockTimeoutError", { + key: Schema.String, + }) {} + + export class LockCompromisedError extends Schema.TaggedErrorClass()("LockCompromisedError", { + detail: Schema.String, + }) {} + + class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }) { + override get message() { + return this.detail + } + } + + /** Internal: signals "lock is held, retry later". Never leaks to callers. */ + class NotAcquired extends Schema.TaggedErrorClass()("NotAcquired", {}) {} + + export type LockError = LockTimeoutError | LockCompromisedError + + // --------------------------------------------------------------------------- + // Timing (baked in — no caller ever overrides these) + // --------------------------------------------------------------------------- + + const STALE_MS = 60_000 + const TIMEOUT_MS = 5 * 60_000 + const BASE_DELAY_MS = 100 + const MAX_DELAY_MS = 2_000 + const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) + + const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + Schedule.jittered, + Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + ) + + // --------------------------------------------------------------------------- + // Lock metadata schema + // --------------------------------------------------------------------------- + + const LockMetaJson = Schema.fromJsonString( + Schema.Struct({ + token: Schema.String, + pid: Schema.Number, + hostname: Schema.String, + createdAt: Schema.String, + }), + ) + + const decodeMeta = Schema.decodeUnknownSync(LockMetaJson) + const encodeMeta = Schema.encodeSync(LockMetaJson) + + // --------------------------------------------------------------------------- + // Service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly withLock: { + (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect + (body: Effect.Effect, key: string, dir?: string): Effect.Effect + } + } + + export class Service extends Context.Service()("EffectFlock") {} + + // --------------------------------------------------------------------------- + // Layer + // --------------------------------------------------------------------------- + + function wall() { + return performance.timeOrigin + performance.now() + } + + const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime() + + const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const fs = yield* AppFileSystem.Service + const lockRoot = path.join(global.state, "locks") + const hostname = os.hostname() + const ensuredDirs = new Set() + + // -- helpers (close over fs) -- + + const safeStat = (file: string) => + fs.stat(file).pipe( + Effect.catchIf(isPathGone, () => Effect.void), + Effect.orDie, + ) + + const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore) + + /** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */ + const atomicMkdir = (dir: string) => + fs.makeDirectory(dir, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.orDie, + ) + + /** Write with exclusive create — compromised error if file already exists. */ + const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) => + fs.writeFileString(filePath, content, { flag: "wx" }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* forceRemove(lockDir) + return yield* new LockCompromisedError({ detail }) + }), + ), + ) + + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const bs = yield* safeStat(breakerPath) + if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + return false + }) + + const ensureDir = Effect.fnUntraced(function* (dir: string) { + if (ensuredDirs.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + ensuredDirs.add(dir) + }) + + const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const now = wall() + + const hb = yield* safeStat(heartbeatPath) + if (hb) return now - mtimeMs(hb) > STALE_MS + + const meta = yield* safeStat(metaPath) + if (meta) return now - mtimeMs(meta) > STALE_MS + + const dir = yield* safeStat(lockDir) + if (!dir) return false + + return now - mtimeMs(dir) > STALE_MS + }) + + // -- single lock attempt -- + + type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } + + const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) { + const token = randomUUID() + const metaPath = path.join(lockDir, "meta.json") + const heartbeatPath = path.join(lockDir, "heartbeat") + + // Atomic mkdir — the POSIX lock primitive + const created = yield* atomicMkdir(lockDir) + + if (!created) { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + + // Stale — race for breaker ownership + const breakerPath = lockDir + ".breaker" + + const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => cleanStaleBreaker(breakerPath), + ), + Effect.catchIf(isPathGone, () => Effect.succeed(false)), + Effect.orDie, + ) + + if (!claimed) return yield* new NotAcquired() + + // We own the breaker — double-check staleness, nuke, recreate + const recreated = yield* Effect.gen(function* () { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + yield* forceRemove(lockDir) + return yield* atomicMkdir(lockDir) + }).pipe(Effect.ensuring(forceRemove(breakerPath))) + + if (!recreated) return yield* new NotAcquired() + } + + // We own the lock dir — write heartbeat + meta with exclusive create + yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") + + const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) + yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") + + return { token, metaPath, heartbeatPath, lockDir } satisfies Handle + }) + + // -- retry wrapper (preserves Handle type) -- + + const acquireHandle = (lockfile: string, key: string): Effect.Effect => + tryAcquireLockDir(lockfile).pipe( + Effect.retry({ + while: (err) => err._tag === "NotAcquired", + schedule: retrySchedule, + }), + Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + ) + + // -- release -- + + const release = (handle: Handle) => + Effect.gen(function* () { + const raw = yield* fs.readFileString(handle.metaPath).pipe( + Effect.catch((err) => { + if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" })) + return Effect.die(err) + }), + ) + + const parsed = yield* Effect.try({ + try: () => decodeMeta(raw), + catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }), + }).pipe(Effect.orDie) + + if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" })) + + yield* forceRemove(handle.lockDir) + }) + + // -- build service -- + + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const lockDir = dir ?? lockRoot + yield* ensureDir(lockDir) + + const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") + + // acquireRelease: acquire is uninterruptible, release is guaranteed + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + + // Heartbeat fiber — scoped, so it's interrupted before release runs + yield* fs + .utimes(handle.heartbeatPath, new Date(), new Date()) + .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + }) + + const withLock: Interface["withLock"] = Function.dual( + (args) => Effect.isEffect(args[0]), + (body: Effect.Effect, key: string, dir?: string): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + yield* acquire(key, dir) + return yield* body + }), + ), + ) + + return Service.of({ acquire, withLock }) + }), + ) + + export const live = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) +} diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts new file mode 100644 index 0000000000..7fd2e144a2 --- /dev/null +++ b/packages/shared/test/fixture/effect-flock-worker.ts @@ -0,0 +1,64 @@ +import fs from "fs/promises" +import path from "path" +import os from "os" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Global } from "@opencode-ai/shared/global" + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const msg: Msg = JSON.parse(process.argv[2]!) + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +async function job() { + if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) + if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" }) + + try { + if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs) + if (msg.done) await fs.appendFile(msg.done, "1\n") + } finally { + if (msg.active) await fs.rm(msg.active, { force: true }) + } +} + +await Effect.runPromise( + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + yield* flock.withLock( + Effect.promise(() => job()), + msg.key, + msg.dir, + ) + }).pipe(Effect.provide(testLayer)), +).catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/shared/test/util/effect-flock.test.ts b/packages/shared/test/util/effect-flock.test.ts new file mode 100644 index 0000000000..6e094c2e1c --- /dev/null +++ b/packages/shared/test/util/effect-flock.test.ts @@ -0,0 +1,388 @@ +import { describe, expect } from "bun:test" +import { spawn } from "child_process" +import fs from "fs/promises" +import path from "path" +import os from "os" +import { Cause, Effect, Exit, Layer } from "effect" +import { testEffect } from "../lib/effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Global } from "@opencode-ai/shared/global" +import { Hash } from "@opencode-ai/shared/util/hash" + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + +// --------------------------------------------------------------------------- +// Worker subprocess helpers +// --------------------------------------------------------------------------- + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts") + +function run(msg: Msg) { + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + proc.on("close", (code) => { + resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) + }) + }) +} + +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function waitForFile(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + throw new Error(`Timed out waiting for file: ${file}`) +} + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("util.effect-flock", () => { + const it = testEffect(testLayer) + + it.live( + "acquire and release via scoped Effect", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const lockDir = lock(dir, "eflock:acquire") + + yield* Effect.scoped(flock.acquire("eflock:acquire", dir)) + + expect(yield* Effect.promise(() => exists(lockDir))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock data-first", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + "eflock:df", + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock pipeable", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* Effect.sync(() => { + hit = true + }).pipe(flock.withLock("eflock:pipe", dir)) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "writes owner metadata", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:meta" + const file = path.join(lock(dir, key), "meta.json") + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const json = yield* Effect.promise(() => + readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file), + ) + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "breaks stale lock dirs", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale" + const lockDir = lock(dir, key) + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "recovers from stale breaker", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + expect(yield* Effect.promise(() => exists(breaker))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects compromise when lock dir removed", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:compromised" + const lockDir = lock(dir, key) + + const result = yield* flock + .withLock( + Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing") + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects token mismatch", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const result = yield* flock + .withLock( + Effect.promise(async () => { + const json = await readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch") + expect(yield* Effect.promise(() => exists(lockDir))).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "fails on unwritable lock roots", + Effect.gen(function* () { + if (process.platform === "win32") return + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + yield* Effect.promise(async () => { + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + }) + + const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit) + expect(String(result)).toContain("PermissionDenied") + yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true }))) + }), + ) + + it.live( + "enforces mutual exclusion under process contention", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-")) + const dir = path.join(tmp, "locks") + const done = path.join(tmp, "done.log") + const active = path.join(tmp, "active") + const n = 16 + + try { + const out = await Promise.all( + Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + } finally { + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 60_000, + ) + + it.live( + "recovers after a crashed lock owner", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-")) + const dir = path.join(tmp, "locks") + const ready = path.join(tmp, "ready") + + const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 }) + + try { + await waitForFile(ready, 5_000) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) + + // Backdate lock files so they're past STALE_MS (60s) + const lockDir = lock(dir, "eflock:crash") + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {}) + + const done = path.join(tmp, "done.log") + const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 }) + expect(result.code).toBe(0) + expect(result.stderr.toString()).toBe("") + } finally { + await stopWorker(proc).catch(() => {}) + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 30_000, + ) +}) From 4ca809ef4e71ee6d62990c815c82c7ee57395a8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 20:58:48 -0400 Subject: [PATCH 154/154] fix(session): retry 5xx server errors even when isRetryable is unset (#22511) --- packages/opencode/src/session/retry.ts | 5 ++- packages/opencode/test/session/retry.test.ts | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 39eb8cfb74..6aad55f3f8 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -56,7 +56,10 @@ export namespace SessionRetry { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.APIError.isInstance(error)) { - if (!error.data.isRetryable) return undefined + const status = error.data.statusCode + // 5xx errors are transient server failures and should always be retried, + // even when the provider SDK doesn't explicitly mark them as retryable. + if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 314306ba62..2d01a8f354 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -178,6 +178,47 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error)).toBeUndefined() }) + test("retries 500 errors even when isRetryable is false", () => { + const error = new MessageV2.APIError({ + message: "Internal server error", + isRetryable: false, + statusCode: 500, + responseBody: '{"type":"api_error","message":"Internal server error"}', + }).toObject() as MessageV2.APIError + + expect(SessionRetry.retryable(error)).toBe("Internal server error") + }) + + test("retries 502 bad gateway errors", () => { + const error = new MessageV2.APIError({ + message: "Bad gateway", + isRetryable: false, + statusCode: 502, + }).toObject() as MessageV2.APIError + + expect(SessionRetry.retryable(error)).toBe("Bad gateway") + }) + + test("retries 503 service unavailable errors", () => { + const error = new MessageV2.APIError({ + message: "Service unavailable", + isRetryable: false, + statusCode: 503, + }).toObject() as MessageV2.APIError + + expect(SessionRetry.retryable(error)).toBe("Service unavailable") + }) + + test("does not retry 4xx errors when isRetryable is false", () => { + const error = new MessageV2.APIError({ + message: "Bad request", + isRetryable: false, + statusCode: 400, + }).toObject() as MessageV2.APIError + + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + test("retries ZlibError decompression failures", () => { const error = new MessageV2.APIError({ message: "Response decompression failed",