From 1a5913316850b256e497b899e29f7995302dcfa0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 12:00:22 -0400 Subject: [PATCH 001/130] Improve light mode dark mode copy --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 585e1ed2322..a58ff056485 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -606,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise }) { category: "System", }, { - title: "Toggle theme mode", + title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", value: "theme.switch_mode", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") From 0d582f9d3f9b20d5443fdee72aaf653778bf885e Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Fri, 17 Apr 2026 18:22:43 +0200 Subject: [PATCH 002/130] chore: bump gitlab-ai-provider to 6.6.0 (#23057) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index f86e22af26f..2442fcd709c 100644 --- a/bun.lock +++ b/bun.lock @@ -386,7 +386,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.4.2", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3314,7 +3314,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3ce3b4192a4..ace30e9bcb6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -143,7 +143,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.4.2", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", From fde3d9133bdf53d5bab9cf44f7f9ec4a23ae4fb4 Mon Sep 17 00:00:00 2001 From: rasdani <73563550+rasdani@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:28:23 -0700 Subject: [PATCH 003/130] fix(opencode): pass `EXA_API_KEY` to `websearch` tool to avoid rate limits (#16362) Co-authored-by: Dax Raad Co-authored-by: Aiden Cline Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/tool/mcp-exa.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-exa.ts index 638d68c2450..3340d84efd0 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-exa.ts @@ -1,7 +1,9 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = "https://mcp.exa.ai/mcp" +const URL = process.env.EXA_API_KEY + ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` + : "https://mcp.exa.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ From c491161c0c6b69fe95672bba395a13b506940512 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:40:24 -0500 Subject: [PATCH 004/130] chore: bump @ai-sdk/anthropic to 3.0.71 and dependents (#23120) --- bun.lock | 16 ++++++++-------- packages/opencode/package.json | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 2442fcd709c..c78c9ae48d7 100644 --- a/bun.lock +++ b/bun.lock @@ -322,15 +322,15 @@ "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.94", - "@ai-sdk/anthropic": "3.0.70", + "@ai-sdk/amazon-bedrock": "4.0.95", + "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.102", "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.111", + "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", @@ -740,7 +740,7 @@ "@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.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@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-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="], "@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=="], @@ -764,7 +764,7 @@ "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="], + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], @@ -5154,7 +5154,7 @@ "@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.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="], @@ -5172,7 +5172,7 @@ "@ai-sdk/fireworks/@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/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="], + "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], "@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="], @@ -5922,7 +5922,7 @@ "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="], + "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ace30e9bcb6..cadb1fa38db 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -79,15 +79,15 @@ "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.94", - "@ai-sdk/anthropic": "3.0.70", + "@ai-sdk/amazon-bedrock": "4.0.95", + "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.102", "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.111", + "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", From 13dfe569efda341cb7b6d4d163e4aee471e65043 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 12:45:29 -0400 Subject: [PATCH 005/130] tui: fix agent cycling and prompt metadata polish (#23115) --- .../cli/cmd/tui/component/dialog-command.tsx | 1 + .../cli/cmd/tui/component/prompt/index.tsx | 24 ++++++++++++------- .../src/cli/cmd/tui/context/local.tsx | 4 +++- .../opencode/src/cli/cmd/tui/util/signal.ts | 7 ++++-- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index f42ba15ec06..49bf42c63e8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -63,6 +63,7 @@ function init() { useKeyboard((evt) => { if (suspended()) return if (dialog.stack.length > 0) return + if (evt.defaultPrevented) return for (const option of entries()) { if (!isEnabled(option)) continue if (option.keybind && keybind.match(option.keybind, evt)) { 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 06e5a0884e5..98527bf912f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -5,7 +5,7 @@ import path from "path" import { fileURLToPath } from "url" import { Filesystem } from "@/util" import { useLocal } from "@tui/context/local" -import { useTheme } from "@tui/context/theme" +import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" @@ -463,19 +463,25 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return if (props.visible === false || dialog.stack.length > 0) { - input.blur() + if (input.focused) input.blur() return } // Slot/plugin updates can remount the background prompt while a dialog is open. // Keep focus with the dialog and let the prompt reclaim it after the dialog closes. - input.focus() + if (!input.focused) input.focus() }) createEffect(() => { if (!input || input.isDestroyed) return + const capture = + store.mode === "normal" + ? auto()?.visible + ? (["escape", "navigate", "submit", "tab"] as const) + : (["tab"] as const) + : undefined input.traits = { - capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined, + capture, suspend: !!props.disabled || store.mode === "shell", status: store.mode === "shell" ? "SHELL" : undefined, } @@ -870,6 +876,7 @@ export function Prompt(props: PromptProps) { () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) + const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined @@ -931,7 +938,7 @@ export function Prompt(props: PromptProps) { (anchor = r)} visible={props.visible !== false}> }> {(agent) => ( <> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}{" "} - + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + · { - let next = agents().findIndex((x) => x.name === agentStore.current) + direction + const current = this.current() + if (!current) return + let next = agents().findIndex((x) => x.name === current.name) + direction if (next < 0) next = agents().length - 1 if (next >= agents().length) next = 0 const value = agents()[next] diff --git a/packages/opencode/src/cli/cmd/tui/util/signal.ts b/packages/opencode/src/cli/cmd/tui/util/signal.ts index 1c7cc0008d0..7d20ae04ba3 100644 --- a/packages/opencode/src/cli/cmd/tui/util/signal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/signal.ts @@ -8,20 +8,23 @@ export function createDebouncedSignal(value: T, ms: number): [Accessor, Sc export function createFadeIn(show: Accessor, enabled: Accessor) { const [alpha, setAlpha] = createSignal(show() ? 1 : 0) + let revealed = show() createEffect( - on([show, enabled], ([visible, animate], previous) => { + on([show, enabled], ([visible, animate]) => { if (!visible) { setAlpha(0) return } - if (!animate || !previous) { + if (!animate || revealed) { + revealed = true setAlpha(1) return } const start = performance.now() + revealed = true setAlpha(0) const timer = setInterval(() => { From ce0cfb0ea5c86af77b97fc41e9076721f601c052 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 16:46:34 +0000 Subject: [PATCH 006/130] chore: generate --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 +++- 1 file changed, 3 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 98527bf912f..cf26ec1950f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1153,7 +1153,9 @@ export function Prompt(props: PromptProps) { }> {(agent) => ( <> - {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + · From 797953c88d29319deb8e003594f31fe6d2e9b8b3 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 13:01:22 -0400 Subject: [PATCH 007/130] when generating sdk only format sdk, much faster (#23122) --- script/format.ts | 4 +++- script/generate.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/script/format.ts b/script/format.ts index 996de9ad04a..37ab2197d0b 100755 --- a/script/format.ts +++ b/script/format.ts @@ -2,4 +2,6 @@ import { $ } from "bun" -await $`bun run prettier --ignore-unknown --write .` +const dir = Bun.argv[2] ?? "." + +await $`bun run prettier --ignore-unknown --write ${dir}` diff --git a/script/generate.ts b/script/generate.ts index 8fc251d89d4..a99d3e77efb 100755 --- a/script/generate.ts +++ b/script/generate.ts @@ -6,4 +6,4 @@ await $`bun ./packages/sdk/js/script/build.ts` await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode") -await $`./script/format.ts` +await $`./script/format.ts ./packages/sdk` From fcb473ff64f0767461c27db8942ce41df3e115d3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 17:25:44 +0000 Subject: [PATCH 008/130] 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 54fd991eca7..5fe11895798 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-OPbZUo/fQv2Xsf+NEZV08GLBMN/DXovhRvn2JkesFtY=", - "aarch64-linux": "sha256-WK7xlVLuirKDN5LaqjBn7qpv5bYVtYHZw0qRNKX4xXg=", - "aarch64-darwin": "sha256-BAoAdeLQ+lXDD7Klxoxij683OVVug8KXEMRUqIQAjc8=", - "x86_64-darwin": "sha256-ZOBwNR2gZgc5f+y3VIBBT4qZpeZfg7Of6AaGDOfqsG8=" + "x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=", + "aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=", + "aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=", + "x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc=" } } From a8c78fc005a9a1cff3622a68f65b1df550cb2ccc Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 13:30:09 -0400 Subject: [PATCH 009/130] fix(core): add historical sync on workspace connect (#23121) --- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 28 +++++- .../opencode/src/control-plane/workspace.ts | 97 ++++++++++++++++--- packages/opencode/src/server/proxy.ts | 7 -- .../src/server/routes/instance/sync.ts | 26 ++++- .../test/workspace/workspace-restore.test.ts | 5 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 33 ++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 19 ++++ packages/sdk/openapi.json | 43 +++++++- 8 files changed, 234 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 14d3062886a..6a240ceef87 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { Flag } from "@/flag/flag" import { batch, onCleanup, onMount } from "solid-js" export type EventSource = { @@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ let queue: GlobalEvent[] = [] let timer: Timer | undefined let last = 0 + const retryDelay = 1000 + const maxRetryDelay = 30000 const flush = () => { if (queue.length === 0) return @@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const ctrl = new AbortController() sse = ctrl ;(async () => { + let attempt = 0 while (true) { if (abort.signal.aborted || ctrl.signal.aborted) break - const events = await sdk.global.event({ signal: ctrl.signal }) + + const events = await sdk.global.event({ + signal: ctrl.signal, + sseMaxRetryAttempts: 0, + }) + + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) + } for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (timer) clearTimeout(timer) if (queue.length > 0) flush() + attempt += 1 + if (abort.signal.aborted || ctrl.signal.aborted) break + + // Exponential backoff + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay) + await new Promise((resolve) => setTimeout(resolve, backoff)) } })().catch(() => {}) } @@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (props.events) { const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) + + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) + } } else { startSSE() } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index fd22d3af04f..d678ad75268 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -7,7 +7,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" +import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { Flag } from "@/flag/flag" import { Log } from "@/util" import { Filesystem } from "@/util" @@ -23,8 +23,8 @@ import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { AppRuntime } from "@/effect/app-runtime" -import { EventSequenceTable } from "@/sync/event.sql" import { waitEvent } from "./util" +import { WorkspaceContext } from "./workspace-context" export const Info = WorkspaceInfo.meta({ ref: "Workspace", @@ -297,22 +297,13 @@ export function list(project: Project.Info) { 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 } -function lookup(id: WorkspaceID) { +export const get = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return return fromRow(row) -} - -export const get = fn(WorkspaceID.zod, async (id) => { - const space = lookup(id) - if (!space) return - startSync(space) - return space }) export const remove = fn(WorkspaceID.zod, async (id) => { @@ -437,6 +428,70 @@ async function connectSSE(url: URL | string, headers: HeadersInit | undefined, s return res.body } +async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { + const sessionIDs = Database.use((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, space.id)) + .all() + .map((row) => row.id), + ) + const state = sessionIDs.length + ? Object.fromEntries( + Database.use((db) => + db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), + ).map((row) => [row.aggregate_id, row.seq]), + ) + : {} + + log.info("syncing workspace history", { + workspaceID: space.id, + sessions: sessionIDs.length, + known: Object.keys(state).length, + }) + + const requestHeaders = new Headers(headers) + requestHeaders.set("content-type", "application/json") + + const res = await fetch(route(url, "/sync/history"), { + method: "POST", + headers: requestHeaders, + body: JSON.stringify(state), + signal, + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`) + } + + const events = await res.json() + + return WorkspaceContext.provide({ + workspaceID: space.id, + fn: () => { + for (const event of events) { + SyncEvent.replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, + ) + } + }, + }) + + log.info("workspace history synced", { + workspaceID: space.id, + events: events.length, + }) +} + async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { const adaptor = await getAdaptor(space.projectID, space.type) const target = await adaptor.target(space) @@ -452,7 +507,9 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { let stream try { stream = await connectSSE(target.url, target.headers, signal) + await syncHistory(space, target.url, target.headers, signal) } catch (err) { + stream = null setStatus(space.id, "error") log.info("failed to connect to global sync", { workspace: space.name, @@ -469,6 +526,7 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { await parseSSE(stream, signal, (evt: any) => { try { if (!("payload" in evt)) return + if (evt.payload.type === "server.heartbeat") return if (evt.payload.type === "sync") { SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent) @@ -536,4 +594,19 @@ function stopSync(id: WorkspaceID) { connections.delete(id) } +export function startWorkspaceSyncing(projectID: ProjectID) { + const spaces = Database.use((db) => + db + .select({ workspace: WorkspaceTable }) + .from(WorkspaceTable) + .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) + .where(eq(WorkspaceTable.project_id, projectID)) + .all(), + ) + + for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) { + void startSync(fromRow(row)) + } +} + export * as Workspace from "./workspace" diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 9c1fd1f288e..19a623cb0c2 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -130,13 +130,6 @@ export async function http(url: string | URL, extra: HeadersInit | undefined, re const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve() return done.then(async () => { - 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, diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index c6a067997b0..b124cd875d7 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -6,6 +6,8 @@ import { Database, asc, and, not, or, lte, eq } from "@/storage" import { EventTable } from "@/sync/event.sql" import { lazy } from "@/util/lazy" import { Log } from "@/util" +import { startWorkspaceSyncing } from "@/control-plane/workspace" +import { Instance } from "@/project/instance" import { errors } from "../../error" const ReplayEvent = z.object({ @@ -20,6 +22,28 @@ const log = Log.create({ service: "server.sync" }) export const SyncRoutes = lazy(() => new Hono() + .post( + "/start", + describeRoute({ + summary: "Start workspace sync", + description: "Start sync loops for workspaces in the current project that have active sessions.", + operationId: "sync.start", + responses: { + 200: { + description: "Workspace sync started", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + startWorkspaceSyncing(Instance.project.id) + return c.json(true) + }, + ) .post( "/replay", describeRoute({ @@ -75,7 +99,7 @@ export const SyncRoutes = lazy(() => }) }, ) - .get( + .post( "/history", describeRoute({ summary: "List sync events", diff --git a/packages/opencode/test/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts index 429eeaf9dde..ad6ac2c5fd8 100644 --- a/packages/opencode/test/workspace/workspace-restore.test.ts +++ b/packages/opencode/test/workspace/workspace-restore.test.ts @@ -141,9 +141,12 @@ describe("Workspace.sessionRestore", () => { Object.assign( async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) - if (url.pathname !== "/base/sync/replay") { + if (url.pathname === "/base/global/event") { return eventStreamResponse() } + if (url.pathname === "/base/sync/history") { + return Response.json([]) + } const body = JSON.parse(String(init?.body)) posts.push({ path: url.pathname, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f484147a40c..6248eb8e4d6 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -163,6 +163,7 @@ import type { SyncHistoryListResponses, SyncReplayErrors, SyncReplayResponses, + SyncStartResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -3038,7 +3039,7 @@ export class History extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).post({ url: "/sync/history", ...options, ...params, @@ -3052,6 +3053,36 @@ export class History extends HeyApiClient { } export class Sync extends HeyApiClient { + /** + * Start workspace sync + * + * Start sync loops for workspaces in the current project that have active sessions. + */ + public start( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/start", + ...options, + ...params, + }) + } + /** * Replay sync events * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 460c2bcdfaf..5698cba54f8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4502,6 +4502,25 @@ export type ProviderOauthCallbackResponses = { export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type SyncStartData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/start" +} + +export type SyncStartResponses = { + /** + * Workspace sync started + */ + 200: boolean +} + +export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses] + export type SyncReplayData = { body?: { directory: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7bdf025bbe8..3b811f2fa9d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5224,6 +5224,47 @@ ] } }, + "/sync/start": { + "post": { + "operationId": "sync.start", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Start workspace sync", + "description": "Start sync loops for workspaces in the current project that have active sessions.", + "responses": { + "200": { + "description": "Workspace sync started", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.start({\n ...\n})" + } + ] + } + }, "/sync/replay": { "post": { "operationId": "sync.replay", @@ -5328,7 +5369,7 @@ } }, "/sync/history": { - "get": { + "post": { "operationId": "sync.history.list", "parameters": [ { From 4c30a78cd9623fe8f3a7c27860a7b8a0cc760e39 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 13:33:11 -0400 Subject: [PATCH 010/130] fix: revert sdk generation script change (#23133) --- script/format.ts | 4 +--- script/generate.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/script/format.ts b/script/format.ts index 37ab2197d0b..996de9ad04a 100755 --- a/script/format.ts +++ b/script/format.ts @@ -2,6 +2,4 @@ import { $ } from "bun" -const dir = Bun.argv[2] ?? "." - -await $`bun run prettier --ignore-unknown --write ${dir}` +await $`bun run prettier --ignore-unknown --write .` diff --git a/script/generate.ts b/script/generate.ts index a99d3e77efb..8fc251d89d4 100755 --- a/script/generate.ts +++ b/script/generate.ts @@ -6,4 +6,4 @@ await $`bun ./packages/sdk/js/script/build.ts` await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode") -await $`./script/format.ts ./packages/sdk` +await $`./script/format.ts` From 2f73e73e9d03262fb59d4e942b3e1e073cb76cb9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 14:08:23 -0400 Subject: [PATCH 011/130] trace npm fully --- .opencode/opencode.jsonc | 6 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 4 +- packages/opencode/src/cli/cmd/tui/layer.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 5 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/effect/memo-map.ts | 3 + packages/opencode/src/effect/run-service.ts | 3 +- .../opencode/src/{cli => }/effect/runtime.ts | 5 +- packages/opencode/src/npm/effect.ts | 261 ++++++++++++++++++ packages/opencode/src/plugin/shared.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 1 - packages/shared/src/npm.ts | 249 ----------------- packages/shared/test/npm.test.ts | 18 -- 16 files changed, 279 insertions(+), 288 deletions(-) create mode 100644 packages/opencode/src/effect/memo-map.ts rename packages/opencode/src/{cli => }/effect/runtime.ts (90%) create mode 100644 packages/opencode/src/npm/effect.ts delete mode 100644 packages/shared/src/npm.ts delete mode 100644 packages/shared/test/npm.test.ts diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 8380f7f719e..82ab6d1b35b 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,10 +1,6 @@ { "$schema": "https://opencode.ai/config.json", - "provider": { - "opencode": { - "options": {}, - }, - }, + "provider": {}, "permission": { "edit": { "packages/opencode/migration/*": "deny", diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index a5c9ae04302..abcf11fcef0 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@/global" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Npm } from "@opencode-ai/shared/npm" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" import { InstallationLocal, InstallationVersion } from "@/installation/version" -import { makeRuntime } from "@/cli/effect/runtime" +import { makeRuntime } from "@/effect/runtime" import { Filesystem, Log } from "@/util" import { ConfigVariable } from "@/config/variable" +import { Npm } from "@/npm/effect" const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts index 734106f8a67..66497f8b1af 100644 --- a/packages/opencode/src/cli/cmd/tui/layer.ts +++ b/packages/opencode/src/cli/cmd/tui/layer.ts @@ -1,6 +1,6 @@ import { Layer } from "effect" import { TuiConfig } from "./config/tui" -import { Npm } from "@opencode-ai/shared/npm" +import { Npm } from "@/npm/effect" import { Observability } from "@/effect/observability" export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ebd4a41fcb8..8980765b793 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -24,7 +24,6 @@ import { InstanceState } from "@/effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" -import { Npm } from "@opencode-ai/shared/npm" import { ConfigAgent } from "./agent" import { ConfigMCP } from "./mcp" import { ConfigModelID } from "./model-id" @@ -39,6 +38,7 @@ import { ConfigPaths } from "./paths" import { ConfigFormatter } from "./formatter" import { ConfigLSP } from "./lsp" import { ConfigVariable } from "./variable" +import { Npm } from "@/npm/effect" const log = Log.create({ service: "config" }) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index eae52d6366d..262d85e7ea5 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,5 +1,5 @@ import { Layer, ManagedRuntime } from "effect" -import { attach, memoMap } from "./run-service" +import { attach } from "./run-service" import * as Observability from "./observability" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -46,7 +46,8 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" -import { Npm } from "@opencode-ai/shared/npm" +import { Npm } from "@/npm/effect" +import { memoMap } from "./memo-map" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 62b71e58b10..37698c43a5d 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -1,5 +1,4 @@ import { Layer, ManagedRuntime } from "effect" -import { memoMap } from "./run-service" import { Plugin } from "@/plugin" import { LSP } from "@/lsp" @@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot" import { Bus } from "@/bus" import { Config } from "@/config" import * as Observability from "./observability" +import { memoMap } from "./memo-map" export const BootstrapLayer = Layer.mergeAll( Config.defaultLayer, diff --git a/packages/opencode/src/effect/memo-map.ts b/packages/opencode/src/effect/memo-map.ts new file mode 100644 index 00000000000..c797dbf42ed --- /dev/null +++ b/packages/opencode/src/effect/memo-map.ts @@ -0,0 +1,3 @@ +import { Layer } from "effect" + +export const memoMap = Layer.makeMemoMapUnsafe() diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28265f9b279..98ff83ea59d 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref" import * as Observability from "./observability" import { WorkspaceContext } from "@/control-plane/workspace-context" import type { InstanceContext } from "@/project/instance" - -export const memoMap = Layer.makeMemoMapUnsafe() +import { memoMap } from "./memo-map" type Refs = { instance?: InstanceContext diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts similarity index 90% rename from packages/opencode/src/cli/effect/runtime.ts rename to packages/opencode/src/effect/runtime.ts index 57b9f8ede9d..ad7872f0b56 100644 --- a/packages/opencode/src/cli/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,7 +1,6 @@ -import { Observability } from "@/effect/observability" +import { Observability } from "./observability" import { Layer, type Context, ManagedRuntime, type Effect } from "effect" - -export const memoMap = Layer.makeMemoMapUnsafe() +import { memoMap } from "./memo-map" export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts new file mode 100644 index 00000000000..10b5ff179c3 --- /dev/null +++ b/packages/opencode/src/npm/effect.ts @@ -0,0 +1,261 @@ +export * as Npm from "./effect" + +import path from "path" +import semver from "semver" +import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Global } from "@opencode-ai/shared/global" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" + +import { makeRuntime } from "../effect/runtime" + +export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { + add: Schema.Array(Schema.String).pipe(Schema.optional), + dir: 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, + input?: { add: 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 +} + +const reify = (input: { dir: string; add?: string[] }) => + Effect.gen(function* () { + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const arborist = new Arborist({ + path: input.dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + return yield* Effect.tryPromise({ + try: () => + arborist.reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }), + catch: (cause) => + new InstallFailedError({ + cause, + add: input?.add, + dir: input.dir, + }), + }) as Effect.Effect + }).pipe( + Effect.withSpan("Npm.reify", { + attributes: input, + }), + ) + +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 flock = yield* EffectFlock.Service + 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.acquire(`npm-install:${dir}`) + + const tree = yield* reify({ dir, add: [pkg] }) + const first = tree.edgesOut.values().next().value?.to + if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + return resolveEntryPoint(first.name, first.path) + }, Effect.scoped) + + const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + const canWrite = yield* afs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + if (!canWrite) return + + yield* flock.acquire(`npm-install:${dir}`) + + yield* Effect.gen(function* () { + const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) + if (!nodeModulesExists) { + yield* reify({ add: input?.add, dir }) + return + } + }).pipe(Effect.withSpan("Npm.checkNodeModules")) + + yield* Effect.gen(function* () { + 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 || {}), + ...(input?.add || []), + ]) + + 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({ dir, add: input?.add }) + return + } + } + }).pipe(Effect.withSpan("Npm.checkDirty")) + + 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(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), +) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function install(...args: Parameters) { + return runPromise((svc) => svc.install(...args)) +} + +export async function add(...args: Parameters) { + return runPromise((svc) => svc.add(...args)) +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 11f36c41ae8..f431204fc4e 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -2,9 +2,9 @@ import path from "path" import { fileURLToPath, pathToFileURL } from "url" import npa from "npm-package-arg" import semver from "semver" -import { Npm } from "../npm" import { Filesystem } from "@/util" import { isRecord } from "@/util/record" +import { Npm } from "@/npm/effect" // Old npm package names for plugins that are now built-in export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b4442d64004..d012e2c166a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -4,7 +4,6 @@ import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Observability } from "@/effect" -import { memoMap } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" @@ -15,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" +import { memoMap } from "@/effect/memo-map" const Query = Schema.Struct({ directory: Schema.optional(Schema.String), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a321b558cfb..7b01ee626a5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -27,7 +27,7 @@ import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" import { ConfigPlugin } from "@/config/plugin" -import { Npm } from "@opencode-ai/shared/npm" +import { Npm } from "@/npm/effect" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c3d7074bfbf..7456990ad0e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -15,7 +15,6 @@ import { Tool } from "../../src/tool" import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Npm } from "@opencode-ai/shared/npm" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts deleted file mode 100644 index 865e827b318..00000000000 --- a/packages/shared/src/npm.ts +++ /dev/null @@ -1,249 +0,0 @@ -import path from "path" -import semver from "semver" -import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "./filesystem" -import { Global } from "./global" -import { EffectFlock } from "./util/effect-flock" - -export namespace Npm { - export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { - add: Schema.Array(Schema.String).pipe(Schema.optional), - dir: 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, - input?: { add: 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 - } - - const reify = (input: { dir: string; add?: string[] }) => - Effect.gen(function* () { - const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) - const arborist = new Arborist({ - path: input.dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - return yield* Effect.tryPromise({ - try: () => - arborist.reify({ - add: input?.add || [], - save: true, - saveType: "prod", - }), - catch: (cause) => - new InstallFailedError({ - cause, - add: input?.add, - dir: input.dir, - }), - }) as Effect.Effect - }).pipe( - Effect.withSpan("Npm.reify", { - attributes: input, - }), - ) - - 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 flock = yield* EffectFlock.Service - 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.acquire(`npm-install:${dir}`) - - const tree = yield* reify({ dir, add: [pkg] }) - const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) - return resolveEntryPoint(first.name, first.path) - }, Effect.scoped) - - const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { - const canWrite = yield* afs.access(dir, { writable: true }).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ) - if (!canWrite) return - - yield* flock.acquire(`npm-install:${dir}`) - - yield* Effect.gen(function* () { - const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) - if (!nodeModulesExists) { - yield* reify({ add: input?.add, dir }) - return - } - }).pipe(Effect.withSpan("Npm.checkNodeModules")) - - yield* Effect.gen(function* () { - 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 || {}), - ...(input?.add || []), - ]) - - 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({ dir, add: input?.add }) - return - } - } - }).pipe(Effect.withSpan("Npm.checkDirty")) - - 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(EffectFlock.layer), - Layer.provide(AppFileSystem.layer), - Layer.provide(Global.layer), - Layer.provide(NodeFileSystem.layer), - ) -} diff --git a/packages/shared/test/npm.test.ts b/packages/shared/test/npm.test.ts deleted file mode 100644 index 4443d2985c8..00000000000 --- a/packages/shared/test/npm.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -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) - }) -}) From 992435aaf8371dc784f9f3489e998e5c93451d18 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 14:18:48 -0400 Subject: [PATCH 012/130] do not flock until reify --- packages/opencode/src/npm/effect.ts | 63 ++++++++++++++--------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts index 10b5ff179c3..5968f14519a 100644 --- a/packages/opencode/src/npm/effect.ts +++ b/packages/opencode/src/npm/effect.ts @@ -63,36 +63,6 @@ interface ArboristTree { edgesOut: Map } -const reify = (input: { dir: string; add?: string[] }) => - Effect.gen(function* () { - const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) - const arborist = new Arborist({ - path: input.dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - return yield* Effect.tryPromise({ - try: () => - arborist.reify({ - add: input?.add || [], - save: true, - saveType: "prod", - }), - catch: (cause) => - new InstallFailedError({ - cause, - add: input?.add, - dir: input.dir, - }), - }) as Effect.Effect - }).pipe( - Effect.withSpan("Npm.reify", { - attributes: input, - }), - ) - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -101,6 +71,36 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + const reify = (input: { dir: string; add?: string[] }) => + Effect.gen(function* () { + yield* flock.acquire(`npm-install:${input.dir}`) + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const arborist = new Arborist({ + path: input.dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + return yield* Effect.tryPromise({ + try: () => + arborist.reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }), + catch: (cause) => + new InstallFailedError({ + cause, + add: input?.add, + dir: input.dir, + }), + }) as Effect.Effect + }).pipe( + Effect.withSpan("Npm.reify", { + attributes: input, + }), + ) const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { const response = yield* Effect.tryPromise({ @@ -130,7 +130,6 @@ export const layer = Layer.effect( const add = Effect.fn("Npm.add")(function* (pkg: string) { const dir = directory(pkg) - yield* flock.acquire(`npm-install:${dir}`) const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to @@ -145,8 +144,6 @@ export const layer = Layer.effect( ) if (!canWrite) return - yield* flock.acquire(`npm-install:${dir}`) - yield* Effect.gen(function* () { const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) if (!nodeModulesExists) { From b1f076558cf75c9ae5322f6195e68c3c380e3f9c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 14:33:02 -0400 Subject: [PATCH 013/130] test: align plugin loader npm mocks - switch plugin loader tests to the effect npm module - return Option.none() for mocked npm entrypoints - keep test fixtures aligned with the current Npm.add contract --- .../cli/tui/plugin-loader-entrypoint.test.ts | 15 ++++++------ .../test/plugin/loader-shared.test.ts | 24 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 395e8ce429b..c6c25fcc117 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -1,3 +1,4 @@ +import { Option } from "effect" import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -5,7 +6,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" -import { Npm } from "../../../src/npm" +import { Npm } from "../../../src/npm/effect" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -56,7 +57,7 @@ test("loads npm tui plugin from package ./tui export", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -117,7 +118,7 @@ test("does not use npm package exports dot for tui entry", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -179,7 +180,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -241,7 +242,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -299,7 +300,7 @@ test("does not use npm package main for tui entry", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) const warn = spyOn(console, "warn").mockImplementation(() => {}) const error = spyOn(console, "error").mockImplementation(() => {}) @@ -468,7 +469,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 5072c1e748c..8e3ad5ea0bc 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Option } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" @@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") -const { Npm } = await import("../../src/npm") +const { Npm } = await import("../../src/npm/effect") afterAll(() => { if (disableDefault === undefined) { @@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => { }) const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme } - return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope } + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() } + return { directory: tmp.extra.scope, entrypoint: Option.none() } }) try { @@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() }) try { await load(tmp.path) @@ -927,7 +927,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) const missing: string[] = [] try { @@ -996,7 +996,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { const loaded = await PluginLoader.loadExternal({ From bbb422d1250da1400c9c228d363bebb336e238ca Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:22 -0500 Subject: [PATCH 014/130] chore: bump ai to 6.0.168 and @ai-sdk/gateway to 3.0.104 (#23145) --- bun.lock | 12 +++++------- package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index c78c9ae48d7..8d83d8ab479 100644 --- a/bun.lock +++ b/bun.lock @@ -328,7 +328,7 @@ "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.102", + "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", @@ -692,7 +692,7 @@ "@types/node": "22.13.9", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.2", "dompurify": "3.3.1", @@ -760,7 +760,7 @@ "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "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-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "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-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], @@ -2456,7 +2456,7 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -2516,7 +2516,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], + "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -5702,8 +5702,6 @@ "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/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-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=="], diff --git a/package.json b/package.json index f1ad03afc81..ddd711adaf4 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.48", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cadb1fa38db..2f56b747750 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,7 +85,7 @@ "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.102", + "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", From 467be08e679d82c20164870f067eb759abe5f6ec Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 17 Apr 2026 14:58:37 -0400 Subject: [PATCH 015/130] refactor: consolidate npm exports and trace flock acquisition (#23151) --- .../opencode/src/cli/cmd/tui/config/tui.ts | 2 +- packages/opencode/src/cli/cmd/tui/layer.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/npm/effect.ts | 258 ----------- packages/opencode/src/npm/index.ts | 405 +++++++++++------- packages/opencode/src/plugin/shared.ts | 2 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 15 +- packages/opencode/test/config/config.test.ts | 2 +- .../test/plugin/loader-shared.test.ts | 24 +- packages/shared/src/util/effect-flock.ts | 75 ++-- 11 files changed, 304 insertions(+), 485 deletions(-) delete mode 100644 packages/opencode/src/npm/effect.ts diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index abcf11fcef0..179046e0269 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version" import { makeRuntime } from "@/effect/runtime" import { Filesystem, Log } from "@/util" import { ConfigVariable } from "@/config/variable" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts index 66497f8b1af..64cba08e82e 100644 --- a/packages/opencode/src/cli/cmd/tui/layer.ts +++ b/packages/opencode/src/cli/cmd/tui/layer.ts @@ -1,6 +1,6 @@ import { Layer } from "effect" import { TuiConfig } from "./config/tui" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" import { Observability } from "@/effect/observability" export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8980765b793..459f76961ae 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths" import { ConfigFormatter } from "./formatter" import { ConfigLSP } from "./lsp" import { ConfigVariable } from "./variable" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const log = Log.create({ service: "config" }) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 262d85e7ea5..d68e00a323b 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,7 +46,7 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" import { memoMap } from "./memo-map" export const AppLayer = Layer.mergeAll( diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts deleted file mode 100644 index 5968f14519a..00000000000 --- a/packages/opencode/src/npm/effect.ts +++ /dev/null @@ -1,258 +0,0 @@ -export * as Npm from "./effect" - -import path from "path" -import semver from "semver" -import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Global } from "@opencode-ai/shared/global" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" - -import { makeRuntime } from "../effect/runtime" - -export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { - add: Schema.Array(Schema.String).pipe(Schema.optional), - dir: 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, - input?: { add: 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 flock = yield* EffectFlock.Service - const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) - const reify = (input: { dir: string; add?: string[] }) => - Effect.gen(function* () { - yield* flock.acquire(`npm-install:${input.dir}`) - const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) - const arborist = new Arborist({ - path: input.dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - return yield* Effect.tryPromise({ - try: () => - arborist.reify({ - add: input?.add || [], - save: true, - saveType: "prod", - }), - catch: (cause) => - new InstallFailedError({ - cause, - add: input?.add, - dir: input.dir, - }), - }) as Effect.Effect - }).pipe( - Effect.withSpan("Npm.reify", { - attributes: input, - }), - ) - - 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) - - const tree = yield* reify({ dir, add: [pkg] }) - const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) - return resolveEntryPoint(first.name, first.path) - }, Effect.scoped) - - const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { - const canWrite = yield* afs.access(dir, { writable: true }).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ) - if (!canWrite) return - - yield* Effect.gen(function* () { - const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) - if (!nodeModulesExists) { - yield* reify({ add: input?.add, dir }) - return - } - }).pipe(Effect.withSpan("Npm.checkNodeModules")) - - yield* Effect.gen(function* () { - 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 || {}), - ...(input?.add || []), - ]) - - 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({ dir, add: input?.add }) - return - } - } - }).pipe(Effect.withSpan("Npm.checkDirty")) - - 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(EffectFlock.layer), - Layer.provide(AppFileSystem.layer), - Layer.provide(Global.layer), - Layer.provide(NodeFileSystem.layer), -) - -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function install(...args: Parameters) { - return runPromise((svc) => svc.install(...args)) -} - -export async function add(...args: Parameters) { - return runPromise((svc) => svc.add(...args)) -} diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 425b27f4206..f2425981926 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,198 +1,271 @@ -import semver from "semver" -import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Global } from "../global" -import { Log } from "../util" +export * as Npm from "." + import path from "path" -import { readdir, rm } from "fs/promises" -import { Filesystem } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import semver from "semver" +import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Global } from "@opencode-ai/shared/global" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" + +import { makeRuntime } from "../effect/runtime" + +export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { + add: Schema.Array(Schema.String).pipe(Schema.optional), + dir: 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, + input?: { add: 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 log = Log.create({ service: "npm" }) const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined -export const InstallFailedError = NamedError.create( - "NpmInstallFailedError", - z.object({ - pkg: z.string(), - }), -) - export function sanitize(pkg: string) { if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } -function directory(pkg: string) { - return path.join(Global.Path.cache, "packages", sanitize(pkg)) -} - -function resolveEntryPoint(name: string, dir: string) { - let entrypoint: string | undefined +const resolveEntryPoint = (name: string, dir: string): EntryPoint => { + let entrypoint: Option.Option try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - } catch {} - const result = { + 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, } - return result } -export async function outdated(pkg: string, cachedVersion: string): Promise { - const response = await fetch(`https://registry.npmjs.org/${pkg}`) - if (!response.ok) { - log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion }) - return false - } - - const data = (await response.json()) as { "dist-tags"?: { latest?: string } } - const latestVersion = data?.["dist-tags"]?.latest - if (!latestVersion) { - log.warn("No latest version found, using cached", { pkg, cachedVersion }) - return false - } - - const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion, cachedVersion) - - return semver.lt(cachedVersion, latestVersion) +interface ArboristNode { + name: string + path: string } -export async function add(pkg: string) { - const { Arborist } = await import("@npmcli/arborist") - const dir = directory(pkg) - await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`) - log.info("installing package", { - pkg, - }) +interface ArboristTree { + edgesOut: Map +} - const arborist = new Arborist({ - path: dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - const tree = await arborist.loadVirtual().catch(() => {}) - if (tree) { - const first = tree.edgesOut.values().next().value?.to - if (first) { - return resolveEntryPoint(first.name, first.path) - } - } +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 flock = yield* EffectFlock.Service + const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + const reify = (input: { dir: string; add?: string[] }) => + Effect.gen(function* () { + yield* flock.acquire(`npm-install:${input.dir}`) + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const arborist = new Arborist({ + path: input.dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + return yield* Effect.tryPromise({ + try: () => + arborist.reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }), + catch: (cause) => + new InstallFailedError({ + cause, + add: input?.add, + dir: input.dir, + }), + }) as Effect.Effect + }).pipe( + Effect.withSpan("Npm.reify", { + attributes: input, + }), + ) - const result = await arborist - .reify({ - add: [pkg], - save: true, - saveType: "prod", + 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) }) - .catch((cause) => { - throw new InstallFailedError( - { pkg }, - { - cause, - }, + + const add = Effect.fn("Npm.add")(function* (pkg: string) { + const dir = directory(pkg) + + const tree = yield* reify({ dir, add: [pkg] }) + const first = tree.edgesOut.values().next().value?.to + if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + return resolveEntryPoint(first.name, first.path) + }, Effect.scoped) + + const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + const canWrite = yield* afs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + if (!canWrite) return + + yield* Effect.gen(function* () { + const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) + if (!nodeModulesExists) { + yield* reify({ add: input?.add, dir }) + return + } + }).pipe(Effect.withSpan("Npm.checkNodeModules")) + + yield* Effect.gen(function* () { + 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 || {}), + ...(input?.add || []), + ]) + + 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({ dir, add: input?.add }) + return + } + } + }).pipe(Effect.withSpan("Npm.checkDirty")) + + 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()), ) }) - const first = result.edgesOut.values().next().value?.to - if (!first) throw new InstallFailedError({ pkg }) - return resolveEntryPoint(first.name, first.path) -} - -export async function install(dir: string) { - await using _ = await Flock.acquire(`npm-install:${dir}`) - log.info("checking dependencies", { dir }) - - const reify = async () => { - const { Arborist } = await import("@npmcli/arborist") - const arb = new Arborist({ - path: dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, + return Service.of({ + add, + install, + outdated, + which, }) - await arb.reify().catch(() => {}) - } + }), +) - if (!(await Filesystem.exists(path.join(dir, "node_modules")))) { - log.info("node_modules missing, reifying") - await reify() - return - } +export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), +) - type PackageDeps = Record - type PackageJson = { - dependencies?: PackageDeps - devDependencies?: PackageDeps - peerDependencies?: PackageDeps - optionalDependencies?: PackageDeps - } - const pkg: PackageJson = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) - const lock: { packages?: Record } = await Filesystem.readJson<{ - packages?: Record - }>(path.join(dir, "package-lock.json")).catch(() => ({})) +const { runPromise } = makeRuntime(Service, defaultLayer) - const declared = new Set([ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.devDependencies || {}), - ...Object.keys(pkg.peerDependencies || {}), - ...Object.keys(pkg.optionalDependencies || {}), - ]) - - const root = lock.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)) { - log.info("dependency not in lock file, reifying", { name }) - await reify() - return - } - } - - log.info("dependencies in sync") +export async function install(...args: Parameters) { + return runPromise((svc) => svc.install(...args)) } -export async function which(pkg: string) { - const dir = directory(pkg) - const binDir = path.join(dir, "node_modules", ".bin") - - const pick = async () => { - const files = await readdir(binDir).catch(() => []) - if (files.length === 0) return undefined - if (files.length === 1) return files[0] - // Multiple binaries — resolve from package.json bin field like npx does - const pkgJson = await Filesystem.readJson<{ bin?: string | Record }>( - path.join(dir, "node_modules", pkg, "package.json"), - ).catch(() => undefined) - if (pkgJson?.bin) { - const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = pkgJson.bin - if (typeof bin === "string") return unscoped - const keys = Object.keys(bin) - if (keys.length === 1) return keys[0] - return bin[unscoped] ? unscoped : keys[0] - } - return files[0] +export async function add(...args: Parameters) { + const entry = await runPromise((svc) => svc.add(...args)) + return { + directory: entry.directory, + entrypoint: Option.getOrUndefined(entry.entrypoint), } - - const bin = await pick() - if (bin) return path.join(binDir, bin) - - await rm(path.join(dir, "package-lock.json"), { force: true }) - await add(pkg) - const resolved = await pick() - if (!resolved) return - return path.join(binDir, resolved) } -export * as Npm from "." +export async function outdated(...args: Parameters) { + return runPromise((svc) => svc.outdated(...args)) +} + +export async function which(...args: Parameters) { + const resolved = await runPromise((svc) => svc.which(...args)) + return Option.getOrUndefined(resolved) +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index f431204fc4e..ca821216d45 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -4,7 +4,7 @@ import npa from "npm-package-arg" import semver from "semver" import { Filesystem } from "@/util" import { isRecord } from "@/util/record" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" // Old npm package names for plugins that are now built-in export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index c6c25fcc117..74236afae80 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -1,4 +1,3 @@ -import { Option } from "effect" import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -6,7 +5,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" -import { Npm } from "../../../src/npm/effect" +import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -57,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -118,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -180,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -242,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -300,7 +299,7 @@ test("does not use npm package main for tui entry", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const warn = spyOn(console, "warn").mockImplementation(() => {}) const error = spyOn(console, "error").mockImplementation(() => {}) @@ -469,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 7b01ee626a5..9f2bf9db9a5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -27,7 +27,7 @@ import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" import { ConfigPlugin } from "@/config/plugin" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 8e3ad5ea0bc..83e9d71b4f9 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect, Option } from "effect" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" @@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") -const { Npm } = await import("../../src/npm/effect") +const { Npm } = await import("../../src/npm") afterAll(() => { if (disableDefault === undefined) { @@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => { }) const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() } - return { directory: tmp.extra.scope, entrypoint: Option.none() } + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } + return { directory: tmp.extra.scope, entrypoint: undefined } }) try { @@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) try { await load(tmp.path) @@ -927,7 +927,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const missing: string[] = [] try { @@ -996,7 +996,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { const loaded = await PluginLoader.loadExternal({ diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts index 3e00afc9e4f..16bcf091b4c 100644 --- a/packages/shared/src/util/effect-flock.ts +++ b/packages/shared/src/util/effect-flock.ts @@ -165,55 +165,60 @@ export namespace EffectFlock { 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") + const tryAcquireLockDir = (lockDir: string, key: string) => + Effect.gen(function* () { + 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) + // Atomic mkdir — the POSIX lock primitive + const created = yield* atomicMkdir(lockDir) - if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + if (!created) { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() - // Stale — race for breaker ownership - const breakerPath = lockDir + ".breaker" + // 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, - ) + 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() + 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))) + // 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() - } + if (!recreated) return yield* new NotAcquired() + } - // We own the lock dir — write heartbeat + meta with exclusive create - yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") + // 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") + 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 - }) + return { token, metaPath, heartbeatPath, lockDir } satisfies Handle + }).pipe( + Effect.withSpan("EffectFlock.tryAcquire", { + attributes: { key }, + }), + ) // -- retry wrapper (preserves Handle type) -- const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile).pipe( + tryAcquireLockDir(lockfile, key).pipe( Effect.retry({ while: (err) => err._tag === "NotAcquired", schedule: retrySchedule, From b275b8580d7bd3b884cf535e28c1b826759eb14b Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:14:05 -0400 Subject: [PATCH 016/130] feat(tui): minor UX improvements for workspaces (#23146) --- .../cmd/tui/component/dialog-session-list.tsx | 9 +- .../tui/component/dialog-workspace-create.tsx | 11 ++- .../dialog-workspace-unavailable.tsx | 83 +++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 53 ++++++++++-- .../cli/cmd/tui/routes/session/sidebar.tsx | 20 +++++ 5 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 60ef6087baf..32342e77247 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -139,15 +139,10 @@ export function DialogSessionList() { {desc}{" "} - ■ + ● ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 7ea513edeed..6dcdabe0b9d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -139,7 +139,16 @@ export async function restoreWorkspaceSession(input: { total: result.data.total, }) - await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => { + input.project.workspace.set(input.workspaceID) + + try { + await input.sync.bootstrap({ fatal: false }) + } catch (e) {} + + await Promise.all([ + input.project.workspace.sync(), + input.sync.session.sync(input.sessionID), + ]).catch((err) => { log.error("session restore refresh failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx new file mode 100644 index 00000000000..0c2dd3e2f33 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -0,0 +1,83 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createStore } from "solid-js/store" +import { For } from "solid-js" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" + +export function DialogWorkspaceUnavailable(props: { + onRestore?: () => boolean | void | Promise +}) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + active: "restore" as "cancel" | "restore", + }) + + const options = ["cancel", "restore"] as const + + async function confirm() { + if (store.active === "cancel") { + dialog.clear() + return + } + const result = await props.onRestore?.() + if (result === false) return + } + + useKeyboard((evt) => { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + void confirm() + return + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + setStore("active", "cancel") + return + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + setStore("active", "restore") + } + }) + + return ( + + + + Workspace Unavailable + + dialog.clear()}> + esc + + + + This session is attached to a workspace that is no longer available. + + + Would you like to restore this session into a new workspace? + + + + {(item) => ( + { + setStore("active", item) + void confirm() + }} + > + {item} + + )} + + + + ) +} 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 cf26ec1950f..2e08e66a4a2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" @@ -38,6 +39,8 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" export type PromptProps = { @@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) { const args = useArgs() const sdk = useSDK() const route = useRoute() + const project = useProject() const sync = useSync() const dialog = useDialog() const toast = useToast() @@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) { keybind: "input_submit", category: "Prompt", hidden: true, - onSelect: (dialog) => { + onSelect: async (dialog) => { if (!input.focused) return - void submit() + const handled = await submit() + if (!handled) return + dialog.clear() }, }, @@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } - if (props.disabled) return - if (autocomplete?.visible) return - if (!store.prompt.input) return + if (props.disabled) return false + if (autocomplete?.visible) return false + if (!store.prompt.input) return false const agent = local.agent.current() - if (!agent) return + if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() - return + return true } const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() - return + return false + } + + const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined + const workspaceID = workspaceSession?.workspaceID + const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined + if (props.sessionID && workspaceID && workspaceStatus !== "connected") { + dialog.replace(() => ( + { + dialog.replace(() => ( + + restoreWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: nextWorkspaceID, + sessionID: props.sessionID!, + }) + } + /> + )) + }} + /> + )) + return false } let sessionID = props.sessionID @@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) { variant: "error", }) - return + return true } sessionID = res.data.id @@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) { }) }, 50) input.clear() + return true } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 06bc2706440..4a7b711a032 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,3 +1,4 @@ +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" @@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + const project = useProject() const sync = useSync() const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) + const workspaceStatus = () => { + const workspaceID = session()?.workspaceID + if (!workspaceID) return "error" + return project.workspace.status(workspaceID) ?? "error" + } + const workspaceLabel = () => { + const workspaceID = session()?.workspaceID + if (!workspaceID) return "unknown" + const info = project.workspace.get(workspaceID) + if (!info) return "unknown" + return `${info.type}: ${info.name}` + } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) return ( @@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} + + + {" "} + {workspaceLabel()} + + {session()!.share!.url} From d6e1362fee569e9d9fa9bc0c6c982f64b2027b30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:15:07 +0000 Subject: [PATCH 017/130] chore: generate --- .../src/cli/cmd/tui/component/dialog-workspace-create.tsx | 5 +---- .../cli/cmd/tui/component/dialog-workspace-unavailable.tsx | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 6dcdabe0b9d..a16c98a9f49 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -145,10 +145,7 @@ export async function restoreWorkspaceSession(input: { await input.sync.bootstrap({ fatal: false }) } catch (e) {} - await Promise.all([ - input.project.workspace.sync(), - input.sync.session.sync(input.sessionID), - ]).catch((err) => { + await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => { log.error("session restore refresh failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 0c2dd3e2f33..7a217985348 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -5,9 +5,7 @@ import { For } from "solid-js" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -export function DialogWorkspaceUnavailable(props: { - onRestore?: () => boolean | void | Promise -}) { +export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { const dialog = useDialog() const { theme } = useTheme() const [store, setStore] = createStore({ From 88582566bf2bfd2d26000f0c25735bf48ddeca00 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:18:55 +0000 Subject: [PATCH 018/130] 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 5fe11895798..01366c82dc4 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=", - "aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=", - "aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=", - "x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc=" + "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=", + "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=", + "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=", + "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA=" } } From 5621373bc2d5ca106e9fbb2d2fbc3bc547dd744c Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:20:11 -0400 Subject: [PATCH 019/130] fix(core): move instance middleware after control plane routes (#23150) --- .../src/server/routes/instance/index.ts | 6 ++- .../src/server/routes/instance/middleware.ts | 35 +++++++++++++++ packages/opencode/src/server/server.ts | 44 +++---------------- 3 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/middleware.ts diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 017541b8fc9..c0339fded79 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -15,6 +15,7 @@ import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" +import { WorkspaceID } from "@/control-plane/schema" import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -27,9 +28,10 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceMiddleware } from "./middleware" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { - const app = new Hono() +export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: WorkspaceID): Hono => { + const app = new Hono().use(InstanceMiddleware(workspaceID)) if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts new file mode 100644 index 00000000000..b963268d647 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -0,0 +1,35 @@ +import type { MiddlewareHandler } from "hono" +import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" +import { AppRuntime } from "@/effect/app-runtime" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { WorkspaceID } from "@/control-plane/schema" + +export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { + return async (c, next) => { + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = AppFileSystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + async fn() { + return next() + }, + }) + }, + }) + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2201c75b4c0..f608c2b732b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,16 +1,10 @@ import { generateSpecs } from "hono-openapi" import { Hono } from "hono" -import type { MiddlewareHandler } from "hono" import { adapter } from "#hono" import { lazy } from "@/util/lazy" import { Log } from "@/util" import { Flag } from "@/flag/flag" -import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -48,47 +42,23 @@ function create(opts: { cors?: string[] }) { const runtime = adapter.create(app) - function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { - return async (c, next) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = AppFileSystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return Instance.provide({ - directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - async fn() { - return next() - }, - }) - }, - }) - } - } - if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app - .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route( + "/", + InstanceRoutes( + runtime.upgradeWebSocket, + Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined, + ), + ), runtime, } } return { app: app - .use(InstanceMiddleware()) .route("/", ControlPlaneRoutes()) .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) From 68834cfcc37ce51c1b0b4895b9563a86f1611c9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 15:22:08 -0400 Subject: [PATCH 020/130] fix(opencode): normalize provider metadata and tag otel runs (#23140) --- packages/opencode/src/cli/cmd/tui/thread.ts | 9 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++ packages/opencode/src/effect/observability.ts | 50 +++++++++++-------- packages/opencode/src/file/ripgrep.ts | 5 +- packages/opencode/src/file/ripgrep.worker.ts | 5 +- packages/opencode/src/index.ts | 5 ++ packages/opencode/src/provider/provider.ts | 14 +++--- .../opencode/src/util/opencode-process.ts | 24 +++++++++ .../opencode/test/provider/provider.test.ts | 34 ++++++++++++- 9 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/util/opencode-process.ts diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 96ceb905c5f..e3e9eb81177 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { writeHeapSnapshot } from "v8" import { TuiConfig } from "./config/tui" +import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" declare global { const OPENCODE_WORKER_PATH: string @@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({ return } const cwd = Filesystem.resolve(process.cwd()) + const env = sanitizedProcessEnv({ + [OPENCODE_PROCESS_ROLE]: "worker", + [OPENCODE_RUN_ID]: ensureRunID(), + }) const worker = new Worker(file, { - env: Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ), + env, }) worker.onerror = (e) => { Log.Default.error("thread error", { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 393a407eb0d..8cec99c615f 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" +import { ensureProcessMetadata } from "@/util/opencode-process" + +ensureProcessMetadata("worker") await Log.init({ print: process.argv.includes("--print-logs"), diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index efd16ffc090..1c385d60ae2 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" import * as EffectLogger from "./logger" import { Flag } from "@/flag/flag" import { InstallationChannel, InstallationVersion } from "@/installation/version" +import { ensureProcessMetadata } from "@/util/opencode-process" const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT export const enabled = !!base +const processID = crypto.randomUUID() const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( @@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ) : undefined -const resource = { - serviceName: "opencode", - serviceVersion: InstallationVersion, - attributes: { - "deployment.environment.name": InstallationChannel, - "opencode.client": Flag.OPENCODE_CLIENT, - }, +function resource() { + const processMetadata = ensureProcessMetadata("main") + return { + serviceName: "opencode", + serviceVersion: InstallationVersion, + attributes: { + "deployment.environment.name": InstallationChannel, + "opencode.client": Flag.OPENCODE_CLIENT, + "opencode.process_role": processMetadata.processRole, + "opencode.run_id": processMetadata.runID, + "service.instance.id": processID, + }, + } } -const logs = Logger.layer( - [ - EffectLogger.logger, - OtlpLogger.make({ - url: `${base}/v1/logs`, - resource, - headers, - }), - ], - { mergeWithExisting: false }, -).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) +function logs() { + return Logger.layer( + [ + EffectLogger.logger, + OtlpLogger.make({ + url: `${base}/v1/logs`, + resource: resource(), + headers, + }), + ], + { mergeWithExisting: false }, + ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) +} const traces = async () => { const NodeSdk = await import("@effect/opentelemetry/NodeSdk") @@ -58,7 +68,7 @@ const traces = async () => { context.setGlobalContextManager(mgr) return NodeSdk.layer(() => ({ - resource, + resource: resource(), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ url: `${base}/v1/traces`, @@ -73,7 +83,7 @@ export const layer = !base : Layer.unwrap( Effect.gen(function* () { const trace = yield* Effect.promise(traces) - return Layer.mergeAll(trace, logs) + return Layer.mergeAll(trace, logs()) }), ) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ac450108e12..3f16f6c5013 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep" import { Filesystem } from "@/util" import { Log } from "@/util" +import { sanitizedProcessEnv } from "@/util/opencode-process" const log = Log.create({ service: "ripgrep" }) @@ -157,9 +158,7 @@ type WorkerError = { } function env() { - const env = Object.fromEntries( - Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), - ) + const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts index 62094c7acc7..21a3aef5cc6 100644 --- a/packages/opencode/src/file/ripgrep.worker.ts +++ b/packages/opencode/src/file/ripgrep.worker.ts @@ -1,9 +1,8 @@ import { ripgrep } from "ripgrep" +import { sanitizedProcessEnv } from "@/util/opencode-process" function env() { - const env = Object.fromEntries( - Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), - ) + const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 67de87c2aad..0a3a927b46e 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -38,6 +38,9 @@ import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" +import { ensureProcessMetadata } from "./util/opencode-process" + +const processMetadata = ensureProcessMetadata("main") process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -108,6 +111,8 @@ const cli = yargs(args) Log.Default.info("opencode", { version: InstallationVersion, args: process.argv.slice(2), + process_role: processMetadata.processRole, + run_id: processMetadata.runID, }) const marker = path.join(Global.Path.data, "opencode.db") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 558bcb75a5e..51aca21c779 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model family: model.family, api: { id: model.id, - url: model.provider?.api ?? provider.api!, + url: model.provider?.api ?? provider.api ?? "", npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", }, status: model.status ?? "active", @@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model output: model.limit.output, }, capabilities: { - temperature: model.temperature, - reasoning: model.reasoning, - attachment: model.attachment, - toolcall: model.tool_call, + temperature: model.temperature ?? false, + reasoning: model.reasoning ?? false, + attachment: model.attachment ?? false, + toolcall: model.tool_call ?? true, input: { text: model.modalities?.input?.includes("text") ?? false, audio: model.modalities?.input?.includes("audio") ?? false, @@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model }, interleaved: model.interleaved ?? false, }, - release_date: model.release_date, + release_date: model.release_date ?? "", variants: {}, } @@ -1143,7 +1143,7 @@ const layer: Layer.Layer< existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", }, status: model.status ?? existingModel?.status ?? "active", name, diff --git a/packages/opencode/src/util/opencode-process.ts b/packages/opencode/src/util/opencode-process.ts new file mode 100644 index 00000000000..f59270ad2d5 --- /dev/null +++ b/packages/opencode/src/util/opencode-process.ts @@ -0,0 +1,24 @@ +export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID" +export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE" + +export function ensureRunID() { + return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID()) +} + +export function ensureProcessRole(fallback: "main" | "worker") { + return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback) +} + +export function ensureProcessMetadata(fallback: "main" | "worker") { + return { + runID: ensureRunID(), + processRole: ensureProcessRole(fallback), + } +} + +export function sanitizedProcessEnv(overrides?: Record) { + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + return overrides ? Object.assign(env, overrides) : env +} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index df8fc4e966a..8993020820e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => { }, }, }, - } as ModelsDev.Provider + } as unknown as ModelsDev.Provider const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] expect(model.cost.input).toEqual(5) @@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => { }) }) +test("models.dev normalization fills required response fields", () => { + const provider = { + id: "gateway", + name: "Gateway", + env: [], + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + cost: { + input: 2.5, + output: 15, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + }, + }, + } as unknown as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] + expect(model.api.url).toBe("") + expect(model.capabilities.temperature).toBe(false) + expect(model.capabilities.reasoning).toBe(false) + expect(model.capabilities.attachment).toBe(false) + expect(model.capabilities.toolcall).toBe(true) + expect(model.release_date).toBe("") +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { From aa05b9abe5ff3a4b29b72fb878be969567eda5bb Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:25:58 -0400 Subject: [PATCH 021/130] fix(core): pass OTEL config to workspace env (#23154) --- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/control-plane/workspace.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 3961cd0e2ae..07acd5ce58d 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -28,7 +28,7 @@ export type WorkspaceAdaptor = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise - create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise remove(info: WorkspaceInfo): Promise target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index d678ad75268..e94d6c2c938 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => { OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), OPENCODE_WORKSPACE_ID: config.id, OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, } await adaptor.create(config, env) From f83cecaaf6ee29da70109575595901884cdbc312 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 15:29:32 -0400 Subject: [PATCH 022/130] fix(opencode): untrace streaming event hot paths (#23156) --- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 820c61aa91b..900579f0360 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -213,7 +213,7 @@ export const layer: Layer.Layer< return true }) - const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": yield* status.set(ctx.sessionID, { type: "busy" }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 077cc430975..1941a6704e8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -649,10 +649,10 @@ export const layer: Layer.Layer = return input.partID }) - const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID field: string delta: string }) { From 9b0659d4f9b64cb2416bb340c503037d0778349e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:30:28 +0000 Subject: [PATCH 023/130] chore: generate --- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 900579f0360..9ab74ca3411 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -213,7 +213,7 @@ export const layer: Layer.Layer< return true }) - const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": yield* status.set(ctx.sessionID, { type: "busy" }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 1941a6704e8..5168b80b563 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -649,10 +649,10 @@ export const layer: Layer.Layer = return input.partID }) - const updatePartDelta = Effect.fnUntraced(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID field: string delta: string }) { From 9b6c3971712baaecacb1d09eb21c4d6f77955622 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 17 Apr 2026 20:13:25 +0000 Subject: [PATCH 024/130] release: v1.4.10 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 8d83d8ab479..6e8845c4f21 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -268,7 +268,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -297,7 +297,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -313,7 +313,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.9", + "version": "1.4.10", "bin": { "opencode": "./bin/opencode", }, @@ -458,7 +458,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -493,7 +493,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -508,7 +508,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "1.4.9", + "version": "1.4.10", "bin": { "opencode": "./bin/opencode", }, @@ -532,7 +532,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -567,7 +567,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -616,7 +616,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 0f4ae4228b3..7206bd53949 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.9", + "version": "1.4.10", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 63b6a5c414b..2dbd6301636 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e5351c1a873..323ee013dfc 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.9", + "version": "1.4.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index ef84eae4704..bef6f53e082 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.9", + "version": "1.4.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 439beb15c85..5b56f5678d4 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index b698a088966..dbf63e85005 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d9d24bd32d..97c8713a2b8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 02f1ad83ca6..914978d7da6 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.9", + "version": "1.4.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ffcc975d1b0..55b3673739c 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.9" +version = "1.4.10" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 2ac49a92283..1dc1f32bb15 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.9", + "version": "1.4.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2f56b747750..7d24d7ca31f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.9", + "version": "1.4.10", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a1aa6470dc8..cc1f24b68e0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 27d9188151d..3a6d13680ee 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 383b26d6edf..615cd42c006 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.9", + "version": "1.4.10", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 75974ed39ea..eb23c2036fe 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 525cf209357..e756c33fcad 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index d3a2a25e4c1..0c4a0c64802 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.9", + "version": "1.4.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index a3ed76c8836..83d00c987a7 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.9", + "version": "1.4.10", "publisher": "sst-dev", "repository": { "type": "git", From b708e8431ec3fcda2a53a3c3e3b8608e5edfb16d Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 17 Apr 2026 15:56:52 -0400 Subject: [PATCH 025/130] docs(opencode): annotate plugin loader flow (#23160) --- packages/opencode/src/plugin/loader.ts | 46 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 0245d311e0e..f17673f36ec 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin" import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { + // A normalized plugin declaration derived from config before any filesystem or npm work happens. export type Plan = { spec: string options: ConfigPlugin.Options | undefined deprecated: boolean } + + // A plugin that has been resolved to a concrete target and entrypoint on disk. export type Resolved = Plan & { source: PluginSource target: string entry: string pkg?: PluginPackage } + + // A plugin target we could inspect, but which does not expose the requested kind of entrypoint. export type Missing = Plan & { source: PluginSource target: string pkg?: PluginPackage message: string } + + // A resolved plugin whose module has been imported successfully. export type Loaded = Resolved & { mod: Record } type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Report = { + // Called before each attempt so callers can log initial load attempts and retries uniformly. start?: (candidate: Candidate, retry: boolean) => void + // Called when the package exists but does not provide the requested entrypoint. missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + // Called for operational failures such as install, compatibility, or dynamic import errors. error?: ( candidate: Candidate, retry: boolean, @@ -46,19 +56,25 @@ export namespace PluginLoader { ) => void } + // Normalize a config item into the loader's internal representation. function plan(item: ConfigPlugin.Spec): Plan { const spec = ConfigPlugin.pluginSpecifier(item) return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } + // Resolve a configured plugin into a concrete entrypoint that can later be imported. + // + // The stages here intentionally separate install/target resolution, entrypoint detection, + // and compatibility checks so callers can report the exact reason a plugin was skipped. export async function resolve( plan: Plan, kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } > { + // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" try { target = await resolvePluginTarget(plan.spec) @@ -67,6 +83,7 @@ export namespace PluginLoader { } if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } + // Then inspect the target for the requested server/tui entrypoint. let base try { base = await createPluginEntry(plan.spec, target, kind) @@ -86,6 +103,8 @@ export namespace PluginLoader { }, } + // npm plugins can declare which opencode versions they support; file plugins are treated + // as local development code and skip this compatibility gate. if (base.source === "npm") { try { await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) @@ -96,6 +115,7 @@ export namespace PluginLoader { return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } } + // Import the resolved module only after all earlier validation has succeeded. export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { let mod try { @@ -107,6 +127,8 @@ export namespace PluginLoader { return { ok: true, value: { ...row, mod } } } + // Run one candidate through the full pipeline: resolve, optionally surface a missing entry, + // import the module, and finally let the caller transform the loaded plugin into any result type. async function attempt( candidate: Candidate, kind: PluginKind, @@ -116,11 +138,17 @@ export namespace PluginLoader { report: Report | undefined, ): Promise { const plan = candidate.plan + + // Deprecated plugin packages are silently ignored because they are now built in. if (plan.deprecated) return + report?.start?.(candidate, retry) + const resolved = await resolve(plan, kind) if (!resolved.ok) { if (resolved.stage === "missing") { + // Missing entrypoints are handled separately so callers can still inspect package metadata, + // for example to load theme files from a tui plugin package that has no code entrypoint. if (missing) { const value = await missing(resolved.value, candidate.origin, retry) if (value !== undefined) return value @@ -131,11 +159,15 @@ export namespace PluginLoader { report?.error?.(candidate, retry, resolved.stage, resolved.error) return } + const loaded = await load(resolved.value) if (!loaded.ok) { report?.error?.(candidate, retry, "load", loaded.error, resolved.value) return } + + // The default behavior is to return the successfully loaded plugin as-is, but callers can + // provide a finisher to adapt the result into a more specific runtime shape. if (!finish) return loaded.value as R return finish(loaded.value, candidate.origin, retry) } @@ -149,6 +181,11 @@ export namespace PluginLoader { report?: Report } + // Resolve and load all configured plugins in parallel. + // + // If `wait` is provided, file-based plugins that initially failed are retried once after the + // caller finishes preparing dependencies. This supports local plugins that depend on an install + // step happening elsewhere before their entrypoint becomes loadable. export async function loadExternal(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const list: Array> = [] @@ -160,6 +197,9 @@ export namespace PluginLoader { let deps: Promise | undefined for (let i = 0; i < candidates.length; i++) { if (out[i] !== undefined) continue + + // Only local file plugins are retried. npm plugins already attempted installation during + // the first pass, while file plugins may need the caller's dependency preparation to finish. const candidate = candidates[i] if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue deps ??= input.wait() @@ -167,6 +207,8 @@ export namespace PluginLoader { out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) } } + + // Drop skipped/failed entries while preserving the successful result order. const ready: R[] = [] for (const item of out) if (item !== undefined) ready.push(item) return ready From c2061c6bbfa83147792ab9aec251cf193ccb6b0d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:58:18 +0000 Subject: [PATCH 026/130] chore: generate --- packages/opencode/src/plugin/loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index f17673f36ec..e61612561bc 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -71,8 +71,8 @@ export namespace PluginLoader { kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } > { // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" From 984f5ed6eb0cbf3048d8c1dbd8f643f5a01d2501 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:15:24 -0400 Subject: [PATCH 027/130] fix(opencode): skip share sync for unshared sessions (#23159) --- packages/opencode/src/share/share-next.ts | 41 ++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 3484d5da76c..2622f4f7f0c 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({ export type Share = typeof ShareSchema.Type type State = { - queue: Map }> + queue: Map> scope: Scope.Closeable + shared: Map } type Data = @@ -118,17 +119,20 @@ export const layer = Layer.effect( function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { if (disabled) return + const share = yield* getCached(sessionID) + if (!share) return + const s = yield* InstanceState.get(state) const existing = s.queue.get(sessionID) if (existing) { for (const item of data) { - existing.data.set(key(item), item) + existing.set(key(item), item) } return } const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, { data: next }) + s.queue.set(sessionID, next) yield* flush(sessionID).pipe( Effect.delay(1000), Effect.catchCause((cause) => @@ -143,13 +147,14 @@ export const layer = Layer.effect( const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make() } + const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } yield* Effect.addFinalizer(() => Scope.close(cache.scope, Exit.void).pipe( Effect.andThen( Effect.sync(() => { cache.queue.clear() + cache.shared.clear() }), ), ), @@ -227,6 +232,18 @@ export const layer = Layer.effect( return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) + const getCached = Effect.fnUntraced(function* (sessionID: SessionID) { + const s = yield* InstanceState.get(state) + if (s.shared.has(sessionID)) { + const cached = s.shared.get(sessionID) + return cached === null ? undefined : cached + } + + const share = yield* get(sessionID) + s.shared.set(sessionID, share ?? null) + return share + }) + const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { if (disabled) return const s = yield* InstanceState.get(state) @@ -235,13 +252,13 @@ export const layer = Layer.effect( s.queue.delete(sessionID) - const share = yield* get(sessionID) + const share = yield* getCached(sessionID) if (!share) return const req = yield* request() const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }), + HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }), Effect.flatMap((r) => http.execute(r)), ) @@ -307,6 +324,7 @@ export const layer = Layer.effect( .run(), ) const s = yield* InstanceState.get(state) + s.shared.set(sessionID, result) yield* full(sessionID).pipe( Effect.catchCause((cause) => Effect.sync(() => { @@ -321,8 +339,13 @@ export const layer = Layer.effect( const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { if (disabled) return log.info("removing share", { sessionID }) - const share = yield* get(sessionID) - if (!share) return + const s = yield* InstanceState.get(state) + const share = yield* getCached(sessionID) + if (!share) { + s.shared.delete(sessionID) + s.queue.delete(sessionID) + return + } const req = yield* request() yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( @@ -332,6 +355,8 @@ export const layer = Layer.effect( ) yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + s.shared.delete(sessionID) + s.queue.delete(sessionID) }) return Service.of({ init, url, request, create, remove }) From 6af8ab0df201e245fac503aa8822750662ebf6b9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:20:57 -0400 Subject: [PATCH 028/130] docs(http-api): refresh bridge inventory and clarify Schema.Class vs Struct (#23164) --- packages/opencode/specs/effect/http-api.md | 77 ++++++++++++++++------ 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 71b50250ed9..09cb86a0005 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -189,10 +189,46 @@ Ordering for a route-group migration: SDK shape rule: -- every schema migration must preserve the generated SDK output byte-for-byte -- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema -- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec -- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging +- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below) +- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging + +### Schema.Class vs Schema.Struct + +The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**. + +**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`: + +```ts +export class Info extends Schema.Class("FooConfig")({ ... }) { + static readonly zod = zod(this) +} +``` + +**Schema.Struct** stays anonymous and is inlined everywhere it is referenced: + +```ts +export const Info = Schema.Struct({ ... }).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export type Info = Schema.Schema.Type +``` + +When to use each: + +- Use **Schema.Class** when: + - the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte) + - the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name) +- Use **Schema.Struct** when: + - the type is only used as a nested field inside another named schema + - the original Zod was anonymous and promoting it would bloat SDK types with no import value + +Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape. + +Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior: + +```ts +export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) +``` Temporary exception: @@ -365,17 +401,16 @@ Current instance route inventory: endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject` - `permission` - `bridged` endpoints: `GET /permission`, `POST /permission/:requestID/reply` -- `provider` - `bridged` (partial) - bridged endpoint: `GET /provider/auth` - not yet ported: `GET /provider`, OAuth mutations -- `config` - `next` - best next endpoint: `GET /config/providers` +- `provider` - `bridged` + endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback` +- `config` - `bridged` (partial) + bridged endpoint: `GET /config/providers` later endpoint: `GET /config` defer `PATCH /config` for now -- `project` - `later` - best small reads: `GET /project`, `GET /project/current` +- `project` - `bridged` (partial) + bridged endpoints: `GET /project`, `GET /project/current` defer git-init mutation first -- `workspace` - `later` +- `workspace` - `next` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` defer create/remove mutations first - `file` - `later` @@ -393,12 +428,12 @@ Current instance route inventory: - `tui` - `defer` queue-style UI bridge, weak early `HttpApi` fit -Recommended near-term sequence after the first spike: +Recommended near-term sequence: -1. `provider` auth read endpoint -2. `config` providers read endpoint -3. `project` read endpoints -4. `workspace` read endpoints +1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`) +2. `config` full read endpoint (`GET /config`) +3. `file` JSON read endpoints +4. `mcp` JSON read endpoints ## Checklist @@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike: - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag - [x] verify OTEL spans and HTTP logs flow to motel - [x] bridge question, permission, and provider auth routes -- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations) -- [ ] port `config` read endpoints +- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations) +- [x] port `config` providers read endpoint +- [x] port `project` read endpoints (`GET /project`, `GET /project/current`) +- [ ] port `workspace` read endpoints +- [ ] port `GET /config` full read endpoint +- [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default ## Rule of thumb From 11fa257549a1094b43d25f014e54557ed6aab660 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:25:49 -0400 Subject: [PATCH 029/130] refactor(config): migrate mcp schemas to Effect Schema.Class (#23163) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/mcp.ts | 118 +++++++++--------- .../src/server/routes/instance/mcp.ts | 2 +- 3 files changed, 58 insertions(+), 64 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 459f76961ae..258500d7bdd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -178,7 +178,7 @@ export const Info = z .record( z.string(), z.union([ - ConfigMCP.Info, + ConfigMCP.Info.zod, z .object({ enabled: z.boolean(), diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 5036cd6e4f6..8b77bc4c286 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,68 +1,62 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Local = z - .object({ - type: z.literal("local").describe("Type of MCP server connection"), - command: z.string().array().describe("Command and arguments to run the MCP server"), - environment: z - .record(z.string(), z.string()) - .optional() - .describe("Environment variables to set when running the MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpLocalConfig", - }) +export class Local extends Schema.Class("McpLocalConfig")({ + type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), + command: Schema.mutable(Schema.Array(Schema.String)).annotate({ + description: "Command and arguments to run the MCP server", + }), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: "Environment variables to set when running the MCP server", + }), + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable or disable the MCP server on startup", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + }), +}) { + static readonly zod = zod(this) +} -export const OAuth = z - .object({ - clientId: z - .string() - .optional() - .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), - clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), - scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), - }) - .strict() - .meta({ - ref: "McpOAuthConfig", - }) -export type OAuth = z.infer +export class OAuth extends Schema.Class("McpOAuthConfig")({ + clientId: Schema.optional(Schema.String).annotate({ + description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", + }), + clientSecret: Schema.optional(Schema.String).annotate({ + description: "OAuth client secret (if required by the authorization server)", + }), + scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }), + redirectUri: Schema.optional(Schema.String).annotate({ + description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + }), +}) { + static readonly zod = zod(this) +} -export const Remote = z - .object({ - type: z.literal("remote").describe("Type of MCP server connection"), - url: z.string().describe("URL of the remote MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), - oauth: z - .union([OAuth, z.literal(false)]) - .optional() - .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpRemoteConfig", - }) +export class Remote extends Schema.Class("McpRemoteConfig")({ + type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), + url: Schema.String.annotate({ description: "URL of the remote MCP server" }), + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable or disable the MCP server on startup", + }), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: "Headers to send with the request", + }), + oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ + description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + }), +}) { + static readonly zod = zod(this) +} -export const Info = z.discriminatedUnion("type", [Local, Remote]) -export type Info = z.infer +export const Info = Schema.Union([Local, Remote]) + .annotate({ discriminator: "type" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index 197185bde00..b42cfb53145 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -54,7 +54,7 @@ export const McpRoutes = lazy(() => "json", z.object({ name: z.string(), - config: ConfigMCP.Info, + config: ConfigMCP.Info.zod, }), ), async (c) => { From 54435325b6adf292d720885622f1282eaed98c92 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:26:43 +0000 Subject: [PATCH 030/130] chore: generate --- packages/sdk/openapi.json | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3b811f2fa9d..71e78307cdd 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11428,13 +11428,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "command"], - "additionalProperties": false + "required": ["type", "command"] }, "McpOAuthConfig": { "type": "object", @@ -11455,8 +11452,7 @@ "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - }, - "additionalProperties": false + } }, "McpRemoteConfig": { "type": "object", @@ -11498,13 +11494,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "url"], - "additionalProperties": false + "required": ["type", "url"] }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", From 650a13a6908e23c30e07329f9f3ec816d7a6f2a7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:34:47 -0400 Subject: [PATCH 031/130] refactor(config): migrate lsp schemas to Effect Schema (#23167) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/lsp.ts | 58 ++++++++++++++------------ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 258500d7bdd..039b0598da8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -189,7 +189,7 @@ export const Info = z .optional() .describe("MCP (Model Context Protocol) server configurations"), formatter: ConfigFormatter.Info.optional(), - lsp: ConfigLSP.Info.optional(), + lsp: ConfigLSP.Info.zod.optional(), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: ConfigPermission.Info.optional(), diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 5530a5be56e..94a6d4bc568 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,37 +1,43 @@ export * as ConfigLSP from "./lsp" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import * as LSPServer from "../lsp/server" -export const Disabled = z.object({ - disabled: z.literal(true), -}) +export const Disabled = Schema.Struct({ + disabled: Schema.Literal(true), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Entry = z.union([ +export const Entry = Schema.Union([ Disabled, - z.object({ - command: z.array(z.string()), - extensions: z.array(z.string()).optional(), - disabled: z.boolean().optional(), - env: z.record(z.string(), z.string()).optional(), - initialization: z.record(z.string(), z.any()).optional(), + Schema.Struct({ + command: Schema.mutable(Schema.Array(Schema.String)), + extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + disabled: Schema.optional(Schema.Boolean), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), -]) +]).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine( - (data) => { - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( + withStatics((s) => ({ + zod: zod(s).refine( + (data) => { + if (typeof data === "boolean") return true + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) - return Object.entries(data).every(([id, config]) => { - if (config.disabled) return true - if (serverIds.has(id)) return true - return Boolean(config.extensions) - }) - }, - { - error: "For custom LSP servers, 'extensions' array is required.", - }, + return Object.entries(data).every(([id, config]) => { + if (config.disabled) return true + if (serverIds.has(id)) return true + return Boolean(config.extensions) + }) + }, + { + error: "For custom LSP servers, 'extensions' array is required.", + }, + ), + })), ) -export type Info = z.infer +export type Info = Schema.Schema.Type From d11268ece76d132108feda4bb91f0caacac51719 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:35:42 -0400 Subject: [PATCH 032/130] refactor(config): migrate permission Action/Object/Rule leaves to Effect Schema (#23168) --- packages/opencode/src/config/permission.ts | 63 +++++++++++----------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index af01f6f2a3c..7cfbaec01f7 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,5 +1,8 @@ export * as ConfigPermission from "./permission" +import { Schema } from "effect" import z from "zod" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const permissionPreprocess = (val: unknown) => { if (typeof val === "object" && val !== null && !Array.isArray(val)) { @@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => { return val } -export const Action = z.enum(["ask", "allow", "deny"]).meta({ - ref: "PermissionActionConfig", -}) -export type Action = z.infer +export const Action = Schema.Literals(["ask", "allow", "deny"]) + .annotate({ identifier: "PermissionActionConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Action = Schema.Schema.Type -export const Object = z.record(z.string(), Action).meta({ - ref: "PermissionObjectConfig", -}) -export type Object = z.infer +export const Object = Schema.Record(Schema.String, Action) + .annotate({ identifier: "PermissionObjectConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Object = Schema.Schema.Type -export const Rule = z.union([Action, Object]).meta({ - ref: "PermissionRuleConfig", -}) -export type Rule = z.infer +export const Rule = Schema.Union([Action, Object]) + .annotate({ identifier: "PermissionRuleConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Rule = Schema.Schema.Type const transform = (x: unknown): Record => { if (typeof x === "string") return { "*": x as Action } @@ -41,25 +44,25 @@ export const Info = z z .object({ __originalKeys: z.string().array().optional(), - read: Rule.optional(), - edit: Rule.optional(), - glob: Rule.optional(), - grep: Rule.optional(), - list: Rule.optional(), - bash: Rule.optional(), - task: Rule.optional(), - external_directory: Rule.optional(), - todowrite: Action.optional(), - question: Action.optional(), - webfetch: Action.optional(), - websearch: Action.optional(), - codesearch: Action.optional(), - lsp: Rule.optional(), - doom_loop: Action.optional(), - skill: Rule.optional(), + read: Rule.zod.optional(), + edit: Rule.zod.optional(), + glob: Rule.zod.optional(), + grep: Rule.zod.optional(), + list: Rule.zod.optional(), + bash: Rule.zod.optional(), + task: Rule.zod.optional(), + external_directory: Rule.zod.optional(), + todowrite: Action.zod.optional(), + question: Action.zod.optional(), + webfetch: Action.zod.optional(), + websearch: Action.zod.optional(), + codesearch: Action.zod.optional(), + lsp: Rule.zod.optional(), + doom_loop: Action.zod.optional(), + skill: Rule.zod.optional(), }) - .catchall(Rule) - .or(Action), + .catchall(Rule.zod) + .or(Action.zod), ) .transform(transform) .meta({ From 47f553f9ba9d98056fa5a20fd1a4e6d4d63df016 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 16:39:34 -0400 Subject: [PATCH 033/130] fix(core): more explicit routing to fix workspace instance issue (#23171) --- .../src/server/routes/control/index.ts | 2 -- .../src/server/routes/instance/index.ts | 5 ++--- packages/opencode/src/server/server.ts | 19 +++++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts index 3fd60636fff..60883274a56 100644 --- a/packages/opencode/src/server/routes/control/index.ts +++ b/packages/opencode/src/server/routes/control/index.ts @@ -7,7 +7,6 @@ import { Hono } from "hono" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import z from "zod" import { errors } from "../../error" -import { WorkspaceRoutes } from "./workspace" export function ControlPlaneRoutes(): Hono { const app = new Hono() @@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono { return c.json(true) }, ) - .route("/experimental/workspace", WorkspaceRoutes()) } diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index c0339fded79..ddb7e335ad9 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -15,7 +15,6 @@ import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" -import { WorkspaceID } from "@/control-plane/schema" import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -30,8 +29,8 @@ import { SyncRoutes } from "./sync" import { AppRuntime } from "@/effect/app-runtime" import { InstanceMiddleware } from "./middleware" -export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: WorkspaceID): Hono => { - const app = new Hono().use(InstanceMiddleware(workspaceID)) +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { + const app = new Hono() if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f608c2b732b..8b1f1aee106 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control" import { UIRoutes } from "./routes/ui" import { GlobalRoutes } from "./routes/global" import { WorkspaceRouterMiddleware } from "./workspace" +import { InstanceMiddleware } from "./routes/instance/middleware" +import { WorkspaceRoutes } from "./routes/control/workspace" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -45,14 +47,9 @@ function create(opts: { cors?: string[] }) { if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app + .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route( - "/", - InstanceRoutes( - runtime.upgradeWebSocket, - Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined, - ), - ), + .route("/", InstanceRoutes(runtime.upgradeWebSocket)), runtime, } } @@ -60,7 +57,13 @@ function create(opts: { cors?: string[] }) { return { app: app .route("/", ControlPlaneRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + .route( + "/", + new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)), + ) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, From e7686dbd643241f7e4ebf4a5e37e5ef33ad7797f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:46:05 -0400 Subject: [PATCH 034/130] feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173) --- packages/opencode/src/config/lsp.ts | 38 ++++---- packages/opencode/src/util/effect-zod.ts | 23 ++++- packages/opencode/test/config/lsp.test.ts | 87 +++++++++++++++++++ .../opencode/test/util/effect-zod.test.ts | 61 +++++++++++++ 4 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/test/config/lsp.test.ts diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 94a6d4bc568..6d61c6b8e37 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -20,24 +20,26 @@ export const Entry = Schema.Union([ }), ]).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( - withStatics((s) => ({ - zod: zod(s).refine( - (data) => { - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) - - return Object.entries(data).every(([id, config]) => { - if (config.disabled) return true - if (serverIds.has(id)) return true - return Boolean(config.extensions) - }) - }, - { - error: "For custom LSP servers, 'extensions' array is required.", - }, - ), - })), +/** + * For custom (non-builtin) LSP server entries, `extensions` is required so the + * client knows which files the server should attach to. Builtin server IDs and + * explicitly disabled entries are exempt. + */ +export const requiresExtensionsForCustomServers = Schema.makeFilter>>( + (data) => { + if (typeof data === "boolean") return undefined + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const ok = Object.entries(data).every(([id, config]) => { + if ("disabled" in config && config.disabled) return true + if (serverIds.has(id)) return true + return "extensions" in config && Boolean(config.extensions) + }) + return ok ? undefined : "For custom LSP servers, 'extensions' array is required." + }, ) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) + .check(requiresExtensionsForCustomServers) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 6e99fd4688b..c3240deaac0 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -16,13 +16,34 @@ 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) + let out = body(ast) + for (const check of ast.checks ?? []) { + out = applyCheck(out, check, ast) + } const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) const next = desc ? out.describe(desc) : out return ref ? next.meta({ ref }) : next } +function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check, ast: SchemaAST.AST): z.ZodTypeAny { + if (check._tag === "FilterGroup") { + return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out) + } + return out.superRefine((value, ctx) => { + const issue = check.run(value, ast, {} as any) + if (!issue) return + const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed" + ctx.addIssue({ code: "custom", message }) + }) +} + +function issueMessage(issue: any): string | undefined { + if (typeof issue?.annotations?.message === "string") return issue.annotations.message + if (typeof issue?.message === "string") return issue.message + return undefined +} + function body(ast: SchemaAST.AST): z.ZodTypeAny { if (SchemaAST.isOptional(ast)) return opt(ast) diff --git a/packages/opencode/test/config/lsp.test.ts b/packages/opencode/test/config/lsp.test.ts new file mode 100644 index 00000000000..1d24fe124de --- /dev/null +++ b/packages/opencode/test/config/lsp.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigLSP } from "../../src/config/lsp" + +// The LSP config refinement enforces: any custom (non-builtin) LSP server +// entry must declare an `extensions` array so the client knows which files +// the server should attach to. Builtin server IDs and explicitly disabled +// entries are exempt. +// +// Both validation paths must honor this rule: +// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer) +// - `ConfigLSP.Info.zod.parse(...)` (derived Zod) +// +// `typescript` is a builtin server id (see src/lsp/server.ts). +describe("ConfigLSP.Info refinement", () => { + const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info) + + describe("accepted inputs", () => { + test("true and false pass (top-level toggle)", () => { + expect(decodeEffect(true)).toBe(true) + expect(decodeEffect(false)).toBe(false) + expect(ConfigLSP.Info.zod.parse(true)).toBe(true) + expect(ConfigLSP.Info.zod.parse(false)).toBe(false) + }) + + test("builtin server with no extensions passes", () => { + const input = { typescript: { command: ["typescript-language-server", "--stdio"] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server WITH extensions passes", () => { + const input = { + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("disabled custom server passes (no extensions needed)", () => { + const input = { "my-lsp": { disabled: true as const } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("mix of builtin and custom with extensions passes", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + }) + + describe("rejected inputs", () => { + const expectedMessage = "For custom LSP servers, 'extensions' array is required." + + test("custom server WITHOUT extensions fails via Effect decode", () => { + expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage) + }) + + test("custom server WITHOUT extensions fails via derived Zod", () => { + const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } }) + expect(result.success).toBe(false) + expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true) + }) + + test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => { + // Boolean(['']) is true, so a non-empty array of strings is fine. + // Boolean([]) is also true in JS, so empty arrays are accepted by the + // refinement. This test documents current behavior. + const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server without extensions mixed with a valid builtin still fails", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"] }, + } + expect(() => decodeEffect(input)).toThrow(expectedMessage) + expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 7f7249514d7..2a5cc45f8df 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -186,4 +186,65 @@ describe("util.effect-zod", () => { const schema = json(zod(Parent)) as any expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) }) + + describe("Schema.check translation", () => { + test("filter returning string triggers refinement with that message", () => { + const isEven = Schema.makeFilter((n: number) => + n % 2 === 0 ? undefined : "expected an even number", + ) + const schema = zod(Schema.Number.check(isEven)) + + expect(schema.parse(4)).toBe(4) + const result = schema.safeParse(3) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("expected an even number") + }) + + test("filter returning false triggers refinement with fallback message", () => { + const nonEmpty = Schema.makeFilter((s: string) => s.length > 0) + const schema = zod(Schema.String.check(nonEmpty)) + + expect(schema.parse("hi")).toBe("hi") + const result = schema.safeParse("") + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toMatch(/./) + }) + + test("filter returning undefined passes validation", () => { + const alwaysOk = Schema.makeFilter(() => undefined) + const schema = zod(Schema.Number.check(alwaysOk)) + + expect(schema.parse(42)).toBe(42) + }) + + test("annotations.message on the filter is used when filter returns false", () => { + const positive = Schema.makeFilter( + (n: number) => n > 0, + { message: "must be positive" }, + ) + const schema = zod(Schema.Number.check(positive)) + + const result = schema.safeParse(-1) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("must be positive") + }) + + test("cross-field check on a record flags missing key", () => { + const hasKey = Schema.makeFilter( + (data: Record) => + "required" in data ? undefined : "missing 'required' key", + ) + const schema = zod( + Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey), + ) + + expect(schema.parse({ required: { enabled: true } })).toEqual({ + required: { enabled: true }, + }) + + const result = schema.safeParse({ other: { enabled: true } }) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("missing 'required' key") + }) + }) }) From dc16013b4f0aa5e4d3eb433046965e1274da3990 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:47:05 +0000 Subject: [PATCH 035/130] chore: generate --- packages/opencode/src/config/lsp.ts | 24 +++++++++---------- .../opencode/test/util/effect-zod.test.ts | 18 ++++---------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 6d61c6b8e37..1cf93177e41 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -25,18 +25,18 @@ export const Entry = Schema.Union([ * client knows which files the server should attach to. Builtin server IDs and * explicitly disabled entries are exempt. */ -export const requiresExtensionsForCustomServers = Schema.makeFilter>>( - (data) => { - if (typeof data === "boolean") return undefined - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) - const ok = Object.entries(data).every(([id, config]) => { - if ("disabled" in config && config.disabled) return true - if (serverIds.has(id)) return true - return "extensions" in config && Boolean(config.extensions) - }) - return ok ? undefined : "For custom LSP servers, 'extensions' array is required." - }, -) +export const requiresExtensionsForCustomServers = Schema.makeFilter< + boolean | Record> +>((data) => { + if (typeof data === "boolean") return undefined + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const ok = Object.entries(data).every(([id, config]) => { + if ("disabled" in config && config.disabled) return true + if (serverIds.has(id)) return true + return "extensions" in config && Boolean(config.extensions) + }) + return ok ? undefined : "For custom LSP servers, 'extensions' array is required." +}) export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) .check(requiresExtensionsForCustomServers) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 2a5cc45f8df..1c84c96f3a4 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -189,9 +189,7 @@ describe("util.effect-zod", () => { describe("Schema.check translation", () => { test("filter returning string triggers refinement with that message", () => { - const isEven = Schema.makeFilter((n: number) => - n % 2 === 0 ? undefined : "expected an even number", - ) + const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number")) const schema = zod(Schema.Number.check(isEven)) expect(schema.parse(4)).toBe(4) @@ -218,10 +216,7 @@ describe("util.effect-zod", () => { }) test("annotations.message on the filter is used when filter returns false", () => { - const positive = Schema.makeFilter( - (n: number) => n > 0, - { message: "must be positive" }, - ) + const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" }) const schema = zod(Schema.Number.check(positive)) const result = schema.safeParse(-1) @@ -230,13 +225,10 @@ describe("util.effect-zod", () => { }) test("cross-field check on a record flags missing key", () => { - const hasKey = Schema.makeFilter( - (data: Record) => - "required" in data ? undefined : "missing 'required' key", - ) - const schema = zod( - Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey), + const hasKey = Schema.makeFilter((data: Record) => + "required" in data ? undefined : "missing 'required' key", ) + const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey)) expect(schema.parse({ required: { enabled: true } })).toEqual({ required: { enabled: true }, From b1307d5c2a41c12d6696b31fe46bea7882993753 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:49:36 -0400 Subject: [PATCH 036/130] refactor(config): migrate skills, formatter, console-state to Effect Schema (#23162) --- packages/opencode/src/config/config.ts | 4 ++-- packages/opencode/src/config/console-state.ts | 21 +++++++++--------- packages/opencode/src/config/formatter.ts | 22 +++++++++++-------- packages/opencode/src/config/skills.ts | 21 ++++++++++-------- .../server/routes/instance/experimental.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +++++----- 6 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 039b0598da8..261c5acab44 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -100,7 +100,7 @@ export const Info = z .record(z.string(), ConfigCommand.Info) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"), + skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), watcher: z .object({ ignore: z.array(z.string()).optional(), @@ -188,7 +188,7 @@ export const Info = z ) .optional() .describe("MCP (Model Context Protocol) server configurations"), - formatter: ConfigFormatter.Info.optional(), + formatter: ConfigFormatter.Info.zod.optional(), lsp: ConfigLSP.Info.zod.optional(), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index cf96a4e305a..08668afe4e8 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,15 +1,16 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" -export const ConsoleState = z.object({ - consoleManagedProviders: z.array(z.string()), - activeOrgName: z.string().optional(), - switchableOrgCount: z.number().int().nonnegative(), -}) +export class ConsoleState extends Schema.Class("ConsoleState")({ + consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), + activeOrgName: Schema.optional(Schema.String), + switchableOrgCount: Schema.Number, +}) { + static readonly zod = zod(this) +} -export type ConsoleState = z.infer - -export const emptyConsoleState: ConsoleState = { +export const emptyConsoleState: ConsoleState = ConsoleState.make({ consoleManagedProviders: [], activeOrgName: undefined, switchableOrgCount: 0, -} +}) diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 93b87f02815..8c1f09a2471 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,13 +1,17 @@ export * as ConfigFormatter from "./formatter" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Entry = z.object({ - disabled: z.boolean().optional(), - command: z.array(z.string()).optional(), - environment: z.record(z.string(), z.string()).optional(), - extensions: z.array(z.string()).optional(), -}) +export const Entry = Schema.Struct({ + disabled: Schema.optional(Schema.Boolean), + command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), + extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]) -export type Info = z.infer +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index 38cbf99e7d7..f29d854f50a 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,13 +1,16 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Info = z.object({ - paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), - urls: z - .array(z.string()) - .optional() - .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), -}) +export const Info = Schema.Struct({ + paths: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Additional paths to skill folders", + }), + urls: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", + }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = z.infer +export type Info = Schema.Schema.Type export * as ConfigSkills from "./skills" diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index f7ecc8255ba..d5659346ef6 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -49,7 +49,7 @@ export const ExperimentalRoutes = lazy(() => description: "Active Console provider metadata", content: { "application/json": { - schema: resolver(ConsoleState), + schema: resolver(ConsoleState.zod), }, }, }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5698cba54f8..72a383a6087 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1807,6 +1807,12 @@ export type Provider = { } } +export type ConsoleState = { + consoleManagedProviders: Array + activeOrgName?: string + switchableOrgCount: number +} + export type ToolIds = Array export type ToolListItem = { @@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata */ - 200: { - consoleManagedProviders: Array - activeOrgName?: string - switchableOrgCount: number - } + 200: ConsoleState } export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] From ed0f0225025336ad84c04210850fcd7bfb38f221 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:50:37 +0000 Subject: [PATCH 037/130] chore: generate --- packages/sdk/openapi.json | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71e78307cdd..c3fd00356c2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1607,24 +1607,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "consoleManagedProviders": { - "type": "array", - "items": { - "type": "string" - } - }, - "activeOrgName": { - "type": "string" - }, - "switchableOrgCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["consoleManagedProviders", "switchableOrgCount"] + "$ref": "#/components/schemas/ConsoleState" } } } @@ -12359,6 +12342,24 @@ }, "required": ["id", "name", "source", "env", "options", "models"] }, + "ConsoleState": { + "type": "object", + "properties": { + "consoleManagedProviders": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeOrgName": { + "type": "string" + }, + "switchableOrgCount": { + "type": "number" + } + }, + "required": ["consoleManagedProviders", "switchableOrgCount"] + }, "ToolIDs": { "type": "array", "items": { From 999d8651aab7f9531539f686f685375fcbb19437 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:52:40 -0400 Subject: [PATCH 038/130] feat(server): wrap remaining route handlers in request spans (#23169) --- .../src/server/routes/instance/config.ts | 13 +- .../server/routes/instance/experimental.ts | 170 ++++---- .../src/server/routes/instance/file.ts | 88 ++-- .../src/server/routes/instance/index.ts | 87 ++-- .../src/server/routes/instance/mcp.ts | 80 ++-- .../src/server/routes/instance/permission.ts | 37 +- .../src/server/routes/instance/project.ts | 18 +- .../src/server/routes/instance/provider.ts | 117 +++-- .../src/server/routes/instance/pty.ts | 69 ++- .../src/server/routes/instance/question.ts | 47 +- .../src/server/routes/instance/session.ts | 411 +++++++++--------- .../src/server/routes/instance/tui.ts | 8 +- packages/opencode/src/server/workspace.ts | 5 +- 13 files changed, 550 insertions(+), 600 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 235f5682e22..7f368cd31c2 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -5,7 +5,6 @@ import { Config } from "@/config" import { Provider } from "@/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest } from "./trace" export const ConfigRoutes = lazy(() => @@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) - return c.json(config) - }, + async (c) => + jsonRequest("ConfigRoutes.update", c, function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return config + }), ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index d5659346ef6..9c864949870 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -12,11 +12,11 @@ import { Config } from "@/config" import { ConsoleState } from "@/config/console-state" import { Account } from "@/account/account" import { AccountID, OrgID } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect, Option } from "effect" import { Agent } from "@/agent/agent" +import { jsonRequest, runRequest } from "./trace" const ConsoleOrgOption = z.object({ accountID: z.string(), @@ -55,22 +55,18 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const config = yield* Config.Service - const account = yield* Account.Service - const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { - concurrency: "unbounded", - }) - return { - ...state, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }), - ) - return c.json(result) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.get", c, function* () { + const config = yield* Config.Service + const account = yield* Account.Service + const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { + concurrency: "unbounded", + }) + return { + ...state, + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }), ) .get( "/console/orgs", @@ -89,28 +85,25 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const orgs = await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { - concurrency: "unbounded", - }) - const info = Option.getOrUndefined(active) - return groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ) - }), - ) - return c.json({ orgs }) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () { + const account = yield* Account.Service + const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { + concurrency: "unbounded", + }) + const info = Option.getOrUndefined(active) + const orgs = groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ) + return { orgs } + }), ) .post( "/console/switch", @@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", ConsoleSwitchBody), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () { + const body = c.req.valid("json") + const account = yield* Account.Service + yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) + return true + }), ) .get( "/tool/ids", @@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() => ...errors(400), }, }), - async (c) => { - const ids = await AppRuntime.runPromise( - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - return yield* registry.ids() - }), - ) - return c.json(ids) - }, + async (c) => + jsonRequest("ExperimentalRoutes.tool.ids", c, function* () { + const registry = yield* ToolRegistry.Service + return yield* registry.ids() + }), ) .get( "/tool", @@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() => ), async (c) => { const { provider, model } = c.req.valid("query") - const tools = await AppRuntime.runPromise( + const tools = await runRequest( + "ExperimentalRoutes.tool.list", + c, Effect.gen(function* () { const agents = yield* Agent.Service const registry = yield* ToolRegistry.Service @@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.CreateInput.optional()), - async (c) => { - const body = c.req.valid("json") - const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body))) - return c.json(worktree) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.create", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + return yield* svc.create(body) + }), ) .get( "/worktree", @@ -272,10 +261,11 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id))) - return c.json(sandboxes) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.list", c, function* () { + const svc = yield* Project.Service + return yield* svc.sandboxes(Instance.project.id) + }), ) .delete( "/worktree", @@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.RemoveInput), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) - await AppRuntime.runPromise( - Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () { + const body = c.req.valid("json") + const worktree = yield* Worktree.Service + const project = yield* Project.Service + yield* worktree.remove(body) + yield* project.removeSandbox(Instance.project.id, body.directory) + return true + }), ) .post( "/worktree/reset", @@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.ResetInput), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body))) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + yield* svc.reset(body) + return true + }), ) .get( "/session", @@ -406,15 +399,10 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - return yield* mcp.resources() - }), - ), - ) - }, + async (c) => + jsonRequest("ExperimentalRoutes.resource.list", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index a82e5687d83..bbef679a855 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -1,13 +1,12 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import { Effect } from "effect" import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" import { LSP } from "@/lsp" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" export const FileRoutes = lazy(() => new Hono() @@ -34,13 +33,13 @@ export const FileRoutes = lazy(() => pattern: z.string(), }), ), - async (c) => { - const pattern = c.req.valid("query").pattern - const result = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), - ) - return c.json(result.items) - }, + async (c) => + jsonRequest("FileRoutes.findText", c, function* () { + const pattern = c.req.valid("query").pattern + const svc = yield* Ripgrep.Service + const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 }) + return result.items + }), ) .get( "/find/file", @@ -68,25 +67,17 @@ export const FileRoutes = lazy(() => limit: z.coerce.number().int().min(1).max(200).optional(), }), ), - async (c) => { - const query = c.req.valid("query").query - const dirs = c.req.valid("query").dirs - const type = c.req.valid("query").type - const limit = c.req.valid("query").limit - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => - svc.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }), - ) - }), - ) - return c.json(results) - }, + async (c) => + jsonRequest("FileRoutes.findFile", c, function* () { + const query = c.req.valid("query") + const svc = yield* File.Service + return yield* svc.search({ + query: query.query, + limit: query.limit ?? 10, + dirs: query.dirs !== "false", + type: query.type, + }) + }), ) .get( "/find/symbol", @@ -138,15 +129,11 @@ export const FileRoutes = lazy(() => path: z.string(), }), ), - async (c) => { - const path = c.req.valid("query").path - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.list(path)) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.list", c, function* () { + const svc = yield* File.Service + return yield* svc.list(c.req.valid("query").path) + }), ) .get( "/file/content", @@ -171,15 +158,11 @@ export const FileRoutes = lazy(() => path: z.string(), }), ), - async (c) => { - const path = c.req.valid("query").path - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.read(path)) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.read", c, function* () { + const svc = yield* File.Service + return yield* svc.read(c.req.valid("query").path) + }), ) .get( "/file/status", @@ -198,13 +181,10 @@ export const FileRoutes = lazy(() => }, }, }), - async (c) => { - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.status()) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.status", c, function* () { + const svc = yield* File.Service + return yield* svc.status() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index ddb7e335ad9..5cc51d27abb 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -26,8 +26,8 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" -import { AppRuntime } from "@/effect/app-runtime" import { InstanceMiddleware } from "./middleware" +import { jsonRequest } from "./trace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() @@ -141,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { - concurrency: 2, - }) - return { branch, default_branch } - }), - ), - ) - }, + async (c) => + jsonRequest("InstanceRoutes.vcs.get", c, function* () { + const vcs = yield* Vcs.Service + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { + concurrency: 2, + }) + return { branch, default_branch } + }), ) .get( "/vcs/diff", @@ -178,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { mode: Vcs.Mode, }), ), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff(c.req.valid("query").mode) - }), - ), - ) - }, + async (c) => + jsonRequest("InstanceRoutes.vcs.diff", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff(c.req.valid("query").mode) + }), ) .get( "/command", @@ -206,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) - return c.json(commands) - }, + async (c) => + jsonRequest("InstanceRoutes.command.list", c, function* () { + const svc = yield* Command.Service + return yield* svc.list() + }), ) .get( "/agent", @@ -228,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - return c.json(modes) - }, + async (c) => + jsonRequest("InstanceRoutes.agent.list", c, function* () { + const svc = yield* Agent.Service + return yield* svc.list() + }), ) .get( "/skill", @@ -250,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - return c.json(skills) - }, + async (c) => + jsonRequest("InstanceRoutes.skill.list", c, function* () { + const skill = yield* Skill.Service + return yield* skill.all() + }), ) .get( "/lsp", @@ -277,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) - return c.json(items) - }, + async (c) => + jsonRequest("InstanceRoutes.lsp.status", c, function* () { + const lsp = yield* LSP.Service + return yield* lsp.status() + }), ) .get( "/formatter", @@ -299,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) - }, + async (c) => + jsonRequest("InstanceRoutes.formatter.status", c, function* () { + const svc = yield* Format.Service + return yield* svc.status() + }), ) } diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index b42cfb53145..ce4722933b1 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -2,12 +2,11 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "@/mcp" -import { Config } from "@/config" import { ConfigMCP } from "@/config/mcp" -import { AppRuntime } from "@/effect/app-runtime" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect } from "effect" +import { jsonRequest, runRequest } from "./trace" export const McpRoutes = lazy(() => new Hono() @@ -28,9 +27,11 @@ export const McpRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) - }, + async (c) => + jsonRequest("McpRoutes.status", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.status() + }), ) .post( "/", @@ -57,11 +58,13 @@ export const McpRoutes = lazy(() => config: ConfigMCP.Info.zod, }), ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) - return c.json(result.status) - }, + async (c) => + jsonRequest("McpRoutes.add", c, function* () { + const { name, config } = c.req.valid("json") + const mcp = yield* MCP.Service + const result = yield* mcp.add(name, config) + return result.status + }), ) .post( "/:name/auth", @@ -87,7 +90,9 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const result = await AppRuntime.runPromise( + const result = await runRequest( + "McpRoutes.auth.start", + c, Effect.gen(function* () { const mcp = yield* MCP.Service const supports = yield* mcp.supportsOAuth(name) @@ -129,12 +134,13 @@ export const McpRoutes = lazy(() => code: z.string().describe("Authorization code from OAuth callback"), }), ), - async (c) => { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) - return c.json(status) - }, + async (c) => + jsonRequest("McpRoutes.auth.callback", c, function* () { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const mcp = yield* MCP.Service + return yield* mcp.finishAuth(name, code) + }), ) .post( "/:name/auth/authenticate", @@ -156,7 +162,9 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const result = await AppRuntime.runPromise( + const result = await runRequest( + "McpRoutes.auth.authenticate", + c, Effect.gen(function* () { const mcp = yield* MCP.Service const supports = yield* mcp.supportsOAuth(name) @@ -191,11 +199,13 @@ export const McpRoutes = lazy(() => ...errors(404), }, }), - async (c) => { - const name = c.req.param("name") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) - return c.json({ success: true as const }) - }, + async (c) => + jsonRequest("McpRoutes.auth.remove", c, function* () { + const name = c.req.param("name") + const mcp = yield* MCP.Service + yield* mcp.removeAuth(name) + return { success: true as const } + }), ) .post( "/:name/connect", @@ -214,11 +224,13 @@ export const McpRoutes = lazy(() => }, }), validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) - return c.json(true) - }, + async (c) => + jsonRequest("McpRoutes.connect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.connect(name) + return true + }), ) .post( "/:name/disconnect", @@ -237,10 +249,12 @@ export const McpRoutes = lazy(() => }, }), validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) - return c.json(true) - }, + async (c) => + jsonRequest("McpRoutes.disconnect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.disconnect(name) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts index c3f9c820110..c18f4734b4a 100644 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ b/packages/opencode/src/server/routes/instance/permission.ts @@ -1,11 +1,11 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" export const PermissionRoutes = lazy(() => new Hono() @@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() => }), ), 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") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("PermissionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return true + }), ) .get( "/", @@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() => }, }, }), - async (c) => { - const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) - return c.json(permissions) - }, + async (c) => + jsonRequest("PermissionRoutes.list", c, function* () { + const svc = yield* Permission.Service + return yield* svc.list() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 060542c4b44..5acef6d788e 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -9,6 +9,7 @@ import { errors } from "../../error" import { lazy } from "@/util/lazy" import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" +import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => new Hono() @@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() => async (c) => { const dir = Instance.directory const prev = Instance.project - const next = await AppRuntime.runPromise( + const next = await runRequest( + "ProjectRoutes.initGit", + c, Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) @@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() => }), validator("param", z.object({ projectID: ProjectID.zod })), validator("json", Project.UpdateInput.omit({ projectID: true })), - async (c) => { - const projectID = c.req.valid("param").projectID - const body = c.req.valid("json") - const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID }))) - return c.json(project) - }, + async (c) => + jsonRequest("ProjectRoutes.update", c, function* () { + const projectID = c.req.valid("param").projectID + const body = c.req.valid("json") + const svc = yield* Project.Service + return yield* svc.update({ ...body, projectID }) + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index 57aa895e3d2..617980e39ca 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -6,11 +6,11 @@ import { Provider } from "@/provider" import { ModelsDev } from "@/provider" import { ProviderAuth } from "@/provider" import { ProviderID } from "@/provider/schema" -import { AppRuntime } from "@/effect/app-runtime" import { mapValues } from "remeda" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect } from "effect" +import { jsonRequest } from "./trace" export const ProviderRoutes = lazy(() => new Hono() @@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() => }, }, }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const cfg = yield* Config.Service - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } + async (c) => + jsonRequest("ProviderRoutes.list", c, function* () { + const svc = yield* Provider.Service + const cfg = yield* Config.Service + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value } - const connected = yield* svc.list() - const providers = Object.assign( - mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }), - ) - return c.json({ - all: result.all, - default: result.default, - connected: result.connected, - }) - }, + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }), ) .get( "/auth", @@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) - }, + async (c) => + jsonRequest("ProviderRoutes.auth", c, function* () { + const svc = yield* ProviderAuth.Service + return yield* svc.methods() + }), ) .post( "/:providerID/oauth/authorize", @@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() => }), ), validator("json", ProviderAuth.AuthorizeInput.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, inputs } = c.req.valid("json") - const result = await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.authorize({ - providerID, - method, - inputs, - }), - ), - ) - return c.json(result) - }, + async (c) => + jsonRequest("ProviderRoutes.oauth.authorize", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, inputs } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + return yield* svc.authorize({ + providerID, + method, + inputs, + }) + }), ) .post( "/:providerID/oauth/callback", @@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() => }), ), validator("json", ProviderAuth.CallbackInput.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.callback({ - providerID, - method, - code, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ProviderRoutes.oauth.callback", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + yield* svc.callback({ + providerID, + method, + code, + }) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index b3f71c235c5..a25b66e9ffd 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -8,6 +8,7 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { NotFoundError } from "@/storage" import { errors } from "../../error" +import { jsonRequest, runRequest } from "./trace" export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return new Hono() @@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.list() - }), - ), - ) - }, + async (c) => + jsonRequest("PtyRoutes.list", c, function* () { + const pty = yield* Pty.Service + return yield* pty.list() + }), ) .post( "/", @@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), validator("json", Pty.CreateInput), - async (c) => { - const info = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.create(c.req.valid("json")) - }), - ) - return c.json(info) - }, + async (c) => + jsonRequest("PtyRoutes.create", c, function* () { + const pty = yield* Pty.Service + return yield* pty.create(c.req.valid("json")) + }), ) .get( "/:ptyID", @@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), async (c) => { - const info = await AppRuntime.runPromise( + const info = await runRequest( + "PtyRoutes.get", + c, Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.get(c.req.valid("param").ptyID) @@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), validator("json", Pty.UpdateInput), - async (c) => { - const info = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) - }), - ) - return c.json(info) - }, + async (c) => + jsonRequest("PtyRoutes.update", c, function* () { + const pty = yield* Pty.Service + return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + }), ) .delete( "/:ptyID", @@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - yield* pty.remove(c.req.valid("param").ptyID) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("PtyRoutes.remove", c, function* () { + const pty = yield* Pty.Service + yield* pty.remove(c.req.valid("param").ptyID) + return true + }), ) .get( "/:ptyID/connect", @@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { })() let handler: Handler | undefined if ( - !(await AppRuntime.runPromise( + !(await runRequest( + "PtyRoutes.connect", + c, Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.get(id) @@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.connect(id, socket, cursor) - }), + }).pipe(Effect.withSpan("PtyRoutes.connect.open")), ) ready = true for (const msg of pending) handler?.onMessage(msg) diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts index 9b8f461e39c..51ecb48ccdd 100644 --- a/packages/opencode/src/server/routes/instance/question.ts +++ b/packages/opencode/src/server/routes/instance/question.ts @@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { QuestionID } from "@/question/schema" import { Question } from "@/question" -import { AppRuntime } from "@/effect/app-runtime" import z from "zod" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" const Reply = z.object({ answers: Question.Answer.zod @@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() => }, }, }), - async (c) => { - const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) - return c.json(questions) - }, + async (c) => + jsonRequest("QuestionRoutes.list", c, function* () { + const svc = yield* Question.Service + return yield* svc.list() + }), ) .post( "/:requestID/reply", @@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() => }), ), validator("json", Reply), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await AppRuntime.runPromise( - Question.Service.use((svc) => - svc.reply({ - requestID: params.requestID, - answers: json.answers, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("QuestionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Question.Service + yield* svc.reply({ + requestID: params.requestID, + answers: json.answers, + }) + return true + }), ) .post( "/:requestID/reject", @@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() => requestID: QuestionID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID))) - return c.json(true) - }, + async (c) => + jsonRequest("QuestionRoutes.reject", c, function* () { + const params = c.req.valid("param") + const svc = yield* Question.Service + yield* svc.reject(params.requestID) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index ae6185abb82..bf713935b0f 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Agent } from "@/agent/agent" import { Snapshot } from "@/snapshot" import { Command } from "@/command" @@ -26,7 +25,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" +import { jsonRequest, runRequest } from "./trace" const log = Log.create({ service: "server" }) @@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() => }, }), validator("json", Session.CreateInput), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body))) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.create", c, function* () { + const body = c.req.valid("json") ?? {} + const svc = yield* SessionShare.Service + return yield* svc.create(body) + }), ) .delete( "/:sessionID", @@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() => sessionID: Session.RemoveInput, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.delete", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* Session.Service + yield* svc.remove(sessionID) + return true + }), ) .patch( "/:sessionID", @@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() => .optional(), }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const current = yield* session.get(sessionID) + async (c) => + jsonRequest("SessionRoutes.update", c, function* () { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + const session = yield* Session.Service + const current = yield* session.get(sessionID) - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } + if (updates.title !== undefined) { + yield* session.setTitle({ sessionID, title: updates.title }) + } + if (updates.permission !== undefined) { + yield* session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } + if (updates.time?.archived !== undefined) { + yield* session.setArchived({ sessionID, time: updates.time.archived }) + } - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + return yield* session.get(sessionID) + }), ) // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( @@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() => messageID: MessageID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.init", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + yield* svc.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) + return true + }), ) .post( "/:sessionID/fork", @@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", Session.ForkInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID }))) - return c.json(result) - }, + async (c) => + jsonRequest("SessionRoutes.fork", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* Session.Service + return yield* svc.fork({ ...body, sessionID }) + }), ) .post( "/:sessionID/abort", @@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID))) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.abort", c, function* () { + const svc = yield* SessionPrompt.Service + yield* svc.cancel(c.req.valid("param").sessionID) + return true + }), ) .post( "/:sessionID/share", @@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.share", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.share(sessionID) + return yield* session.get(sessionID) + }), ) .get( "/:sessionID/diff", @@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() => messageID: SessionSummary.DiffInput.shape.messageID, }), ), - async (c) => { - const query = c.req.valid("query") - const params = c.req.valid("param") - const result = await AppRuntime.runPromise( - SessionSummary.Service.use((summary) => - summary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }), - ), - ) - return c.json(result) - }, + async (c) => + jsonRequest("SessionRoutes.diff", c, function* () { + const query = c.req.valid("query") + const params = c.req.valid("param") + const summary = yield* SessionSummary.Service + return yield* summary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + }), ) .delete( "/:sessionID/share", @@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.unshare", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.unshare(sessionID) + return yield* session.get(sessionID) + }), ) .post( "/:sessionID/summarize", @@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() => auto: z.boolean().optional().default(false), }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service + async (c) => + jsonRequest("SessionRoutes.summarize", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } + yield* revert.cleanup(yield* session.get(sessionID)) + const msgs = yield* session.messages({ sessionID }) + const defaultAgent = yield* agent.defaultAgent() + let currentAgent = defaultAgent + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || defaultAgent + break } + } - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.loop({ sessionID }) - }), - ) - return c.json(true) - }, + yield* compact.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + yield* prompt.loop({ sessionID }) + return true + }), ) .get( "/:sessionID/message", @@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() => const query = c.req.valid("query") const sessionID = c.req.valid("param").sessionID if (query.limit === undefined || query.limit === 0) { - const messages = await AppRuntime.runPromise( + const messages = await runRequest( + "SessionRoutes.messages", + c, Effect.gen(function* () { const session = yield* Session.Service yield* session.get(sessionID) @@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() => messageID: MessageID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.deleteMessage", c, function* () { + const params = c.req.valid("param") + const state = yield* SessionRunState.Service + const session = yield* Session.Service + yield* state.assertNotBusy(params.sessionID) + yield* session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return true + }), ) .delete( "/:sessionID/message/:messageID/part/:partID", @@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() => partID: PartID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.deletePart", c, function* () { + const params = c.req.valid("param") + const svc = yield* Session.Service + yield* svc.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return true + }), ) .patch( "/:sessionID/message/:messageID/part/:partID", @@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() => `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, ) } - const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body))) - return c.json(part) + return jsonRequest("SessionRoutes.updatePart", c, function* () { + const svc = yield* Session.Service + return yield* svc.updatePart(body) + }) }, ) .post( @@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() => return stream(c, async (stream) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const msg = await AppRuntime.runPromise( + const msg = await runRequest( + "SessionRoutes.prompt", + c, SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), ) void stream.write(JSON.stringify(msg)) @@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch( - (err) => { - log.error("prompt_async failed", { sessionID, error: err }) - void Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }, - ) + void runRequest( + "SessionRoutes.prompt_async", + c, + SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + ).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + void Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) return c.body(null, 204) }, @@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID }))) - return c.json(msg) - }, + async (c) => + jsonRequest("SessionRoutes.command", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + return yield* svc.command({ ...body, sessionID }) + }), ) .post( "/:sessionID/shell", @@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID }))) - return c.json(msg) - }, + async (c) => + jsonRequest("SessionRoutes.shell", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + return yield* svc.shell({ ...body, sessionID }) + }), ) .post( "/:sessionID/revert", @@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID log.info("revert", c.req.valid("json")) - const session = await AppRuntime.runPromise( - SessionRevert.Service.use((svc) => - svc.revert({ - sessionID, - ...c.req.valid("json"), - }), - ), - ) - return c.json(session) + return jsonRequest("SessionRoutes.revert", c, function* () { + const svc = yield* SessionRevert.Service + return yield* svc.revert({ + sessionID, + ...c.req.valid("json"), + }) + }) }, ) .post( @@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID }))) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.unrevert", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* SessionRevert.Service + return yield* svc.unrevert({ sessionID }) + }), ) .post( "/:sessionID/permissions/:permissionID", @@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() => }), ), validator("json", z.object({ response: Permission.Reply.zod })), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.permissionRespond", c, function* () { + const params = c.req.valid("param") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 2f856c34884..d6add67b974 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -4,10 +4,10 @@ import z from "zod" import { Bus } from "@/bus" import { Session } from "@/session" import { TuiEvent } from "@/cli/cmd/tui/event" -import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { runRequest } from "./trace" const TuiRequest = z.object({ path: z.string(), @@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() => validator("json", TuiEvent.SessionSelect.properties), async (c) => { const { sessionID } = c.req.valid("json") - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) + await runRequest( + "TuiRoutes.sessionSelect", + c, + Session.Service.use((svc) => svc.get(sessionID)), + ) await Bus.publish(TuiEvent.SessionSelect, { sessionID }) return c.json(true) }, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index c141d10956d..d30a117d6a4 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -10,6 +10,7 @@ import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Log } from "@/util" import { ServerProxy } from "./proxy" @@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) { const id = getSessionID(url) if (!id) return null - const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) + const session = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), + ).catch(() => undefined) return session?.workspaceID } From ce69bd97b90200a4d9794bd0409d3b4bb7996e54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:59:24 -0400 Subject: [PATCH 039/130] refactor(config): migrate model-id and command to Effect Schema (#23175) --- packages/opencode/src/config/agent.ts | 2 +- packages/opencode/src/config/command.ts | 22 ++++++++++++---------- packages/opencode/src/config/config.ts | 8 +++++--- packages/opencode/src/config/model-id.ts | 13 ++++++++++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index f754f009d4c..9053b19fc11 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -15,7 +15,7 @@ const log = Log.create({ service: "config" }) export const Info = z .object({ - model: ConfigModelID.optional(), + model: ConfigModelID.zod.optional(), variant: z .string() .optional() diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 97992505672..3e0adccc303 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -1,10 +1,12 @@ export * as ConfigCommand from "./command" import { Log } from "../util" -import z from "zod" +import { Schema } from "effect" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" import { Bus } from "@/bus" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" @@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id" const log = Log.create({ service: "config" }) -export const Info = z.object({ - template: z.string(), - description: z.string().optional(), - agent: z.string().optional(), - model: ConfigModelID.optional(), - subtask: z.boolean().optional(), -}) +export const Info = Schema.Struct({ + template: Schema.String, + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(ConfigModelID), + subtask: Schema.optional(Schema.Boolean), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = z.infer +export type Info = Schema.Schema.Type export async function load(dir: string) { const result: Record = {} @@ -49,7 +51,7 @@ export async function load(dir: string) { ...md.data, template: md.content.trim(), } - const parsed = Info.safeParse(config) + const parsed = Info.zod.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 261c5acab44..684fdf63ab8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -97,7 +97,7 @@ export const Info = z logLevel: Log.Level.optional().describe("Log level"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z - .record(z.string(), ConfigCommand.Info) + .record(z.string(), ConfigCommand.Info.zod) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), @@ -135,8 +135,10 @@ export const Info = z .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), - small_model: ConfigModelID.describe( + model: ConfigModelID.zod + .describe("Model to use in the format of provider/model, eg anthropic/claude-2") + .optional(), + small_model: ConfigModelID.zod.describe( "Small model to use for tasks like title generation in the format of provider/model", ).optional(), default_agent: z diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 909e9aa9292..3ad9e035ce6 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,3 +1,14 @@ +import { Schema } from "effect" import z from "zod" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) +// The original Zod schema carried an external $ref pointing at the models.dev +// JSON schema. That external reference is not a named SDK component — it is a +// literal pointer to an outside schema — so the walker cannot re-derive it +// from AST metadata. Preserve the exact original Zod via ZodOverride. +export const ConfigModelID = Schema.String.annotate({ + [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) + +export type ConfigModelID = Schema.Schema.Type From 89029a20ef1548f6637c15f63f39f281e4a6dae7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 21:00:20 +0000 Subject: [PATCH 040/130] chore: generate --- packages/opencode/src/config/config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 684fdf63ab8..f228878d047 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -135,12 +135,10 @@ export const Info = z .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ConfigModelID.zod - .describe("Model to use in the format of provider/model, eg anthropic/claude-2") + model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + small_model: ConfigModelID.zod + .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), - small_model: ConfigModelID.zod.describe( - "Small model to use for tasks like title generation in the format of provider/model", - ).optional(), default_agent: z .string() .optional() From 5980b0a5eeb8f7a8dc31433f86e458dbe3358269 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 17:06:55 -0400 Subject: [PATCH 041/130] feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) --- .../src/cli/cmd/tui/config/tui-schema.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/plugin.ts | 15 ++++++---- packages/opencode/src/util/effect-zod.ts | 13 +++++++-- .../opencode/test/util/effect-zod.test.ts | 28 +++++++++++++++++-- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 66569efea5b..ed79e8e5241 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -31,7 +31,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f228878d047..0f6d71f4473 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -113,7 +113,7 @@ export const Info = z "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", ), // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 7d335bcc535..ebc3e2230da 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -1,16 +1,21 @@ import { Glob } from "@opencode-ai/shared/util/glob" -import z from "zod" +import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import path from "path" -const Options = z.record(z.string(), z.unknown()) -export type Options = z.infer +export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) -export type Spec = z.infer +export const Spec = Schema.Union([ + Schema.String, + Schema.mutable(Schema.Tuple([Schema.String, Options])), +]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Spec = Schema.Schema.Type export type Scope = "global" | "local" diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index c3240deaac0..771795ba682 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -119,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny { } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - if (ast.elements.length > 0) return fail(ast) - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) + // Pure variadic arrays: { elements: [], rest: [item] } + if (ast.elements.length === 0) { + if (ast.rest.length !== 1) return fail(ast) + return z.array(walk(ast.rest[0])) + } + // Fixed-length tuples: { elements: [a, b, ...], rest: [] } + // Tuples with a variadic tail (...rest) are not yet supported. + if (ast.rest.length > 0) return fail(ast) + const items = ast.elements.map(walk) + return z.tuple(items as [z.ZodTypeAny, ...Array]) } function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 1c84c96f3a4..ba67a60e6dc 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -61,8 +61,32 @@ describe("util.effect-zod", () => { }) }) - test("throws for unsupported tuple schemas", () => { - expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") + describe("Tuples", () => { + test("fixed-length tuple parses matching array", () => { + const out = zod(Schema.Tuple([Schema.String, Schema.Number])) + expect(out.parse(["a", 1])).toEqual(["a", 1]) + expect(out.safeParse(["a"]).success).toBe(false) + expect(out.safeParse(["a", "b"]).success).toBe(false) + }) + + test("single-element tuple parses a one-element array", () => { + const out = zod(Schema.Tuple([Schema.Boolean])) + expect(out.parse([true])).toEqual([true]) + expect(out.safeParse([true, false]).success).toBe(false) + }) + + test("tuple inside a union picks the right branch", () => { + const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])])) + expect(out.parse("hello")).toBe("hello") + expect(out.parse(["foo", 42])).toEqual(["foo", 42]) + expect(out.safeParse(["foo"]).success).toBe(false) + }) + + test("plain arrays still work (no element positions)", () => { + const out = zod(Schema.Array(Schema.String)) + expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]) + expect(out.parse([])).toEqual([]) + }) }) test("string literal unions produce z.enum with enum in JSON Schema", () => { From 89e8994fd15c4ac1235930a07e0b6e37254df22b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 21:08:00 +0000 Subject: [PATCH 042/130] chore: generate --- packages/opencode/src/config/plugin.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index ebc3e2230da..4277c1cd6d7 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -11,10 +11,9 @@ export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = Schema.Union([ - Schema.String, - Schema.mutable(Schema.Tuple([Schema.String, Options])), -]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe( + withStatics((s) => ({ zod: zod(s) })), +) export type Spec = Schema.Schema.Type export type Scope = "global" | "local" From 0068ccec35493e2657678a4ab0654a278bd14685 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:27:32 -0500 Subject: [PATCH 043/130] fix: ensure copilot model list filters out disabled models (#23176) --- packages/opencode/src/plugin/github-copilot/copilot.ts | 1 - packages/opencode/src/plugin/github-copilot/models.ts | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c9b7e3c1c76..c018f72bd57 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -1,6 +1,5 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Model } from "@opencode-ai/sdk/v2" -import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { iife } from "@/util/iife" import { Log } from "../../util" diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 71d21afbe4f..0aac0d3f5ef 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -10,6 +10,11 @@ export const schema = z.object({ // every version looks like: `{model.id}-YYYY-MM-DD` version: z.string(), supported_endpoints: z.array(z.string()).optional(), + policy: z + .object({ + state: z.string().optional(), + }) + .optional(), capabilities: z.object({ family: z.string(), limits: z.object({ @@ -122,7 +127,9 @@ export async function get( }) const result = { ...existing } - const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) + const remote = new Map( + data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const), + ) // prune existing models whose api.id isn't in the endpoint response for (const [key, model] of Object.entries(result)) { From cded68a2e2e233b8026c50408771b25d0f4e9682 Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 17 Apr 2026 17:30:50 -0400 Subject: [PATCH 044/130] refactor(npm): use object-based package spec for install API (#23181) --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 7 ++++++- packages/opencode/src/config/config.ts | 7 ++++++- packages/opencode/src/npm/index.ts | 16 +++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 179046e0269..9d5cd65bfd8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -158,7 +158,12 @@ export const layer = Layer.effect( (dir) => npm .install(dir, { - add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], }) .pipe(Effect.forkScoped), { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0f6d71f4473..a2d62eaa5e6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -518,7 +518,12 @@ export const layer = Layer.effect( const dep = yield* npmSvc .install(dir, { - add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], }) .pipe( Effect.exit, diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index f2425981926..d92099bc3cc 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -25,7 +25,12 @@ export interface Interface { readonly add: (pkg: string) => Effect.Effect readonly install: ( dir: string, - input?: { add: string[] }, + input?: { + add: { + name: string + version?: string + }[] + }, ) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string) => Effect.Effect> @@ -137,17 +142,18 @@ export const layer = Layer.effect( return resolveEntryPoint(first.name, first.path) }, Effect.scoped) - const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) { const canWrite = yield* afs.access(dir, { writable: true }).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ) if (!canWrite) return + const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? [] yield* Effect.gen(function* () { const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) if (!nodeModulesExists) { - yield* reify({ add: input?.add, dir }) + yield* reify({ add, dir }) return } }).pipe(Effect.withSpan("Npm.checkNodeModules")) @@ -163,7 +169,7 @@ export const layer = Layer.effect( ...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.optionalDependencies || {}), - ...(input?.add || []), + ...(input?.add || []).map((pkg) => pkg.name), ]) const root = lockAny?.packages?.[""] || {} @@ -176,7 +182,7 @@ export const layer = Layer.effect( for (const name of declared) { if (!locked.has(name)) { - yield* reify({ dir, add: input?.add }) + yield* reify({ dir, add }) return } } From a35b8a95c27d28e979a3826e1289d7ee87f40251 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 18 Apr 2026 00:29:16 +0000 Subject: [PATCH 045/130] release: v1.4.11 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 6e8845c4f21..3cb3cbea602 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -268,7 +268,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -297,7 +297,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -313,7 +313,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.10", + "version": "1.4.11", "bin": { "opencode": "./bin/opencode", }, @@ -458,7 +458,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -493,7 +493,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -508,7 +508,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "1.4.10", + "version": "1.4.11", "bin": { "opencode": "./bin/opencode", }, @@ -532,7 +532,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -567,7 +567,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -616,7 +616,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 7206bd53949..5a1a4504eab 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.10", + "version": "1.4.11", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 2dbd6301636..200a5e30e3c 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 323ee013dfc..f233726e691 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.10", + "version": "1.4.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index bef6f53e082..1142230bb7e 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.10", + "version": "1.4.11", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 5b56f5678d4..860150aa28c 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.10", + "version": "1.4.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index dbf63e85005..8142b12ada3 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 97c8713a2b8..a23342bdec3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 914978d7da6..f5651596289 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.10", + "version": "1.4.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 55b3673739c..32039c097ac 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.10" +version = "1.4.11" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1dc1f32bb15..5d4229f64f4 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.10", + "version": "1.4.11", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7d24d7ca31f..2acbc4fe84c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.10", + "version": "1.4.11", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index cc1f24b68e0..15cd2db6e24 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3a6d13680ee..91d66474499 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 615cd42c006..a8cd62886be 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.10", + "version": "1.4.11", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index eb23c2036fe..8ca990ba58f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index e756c33fcad..98cb928b7bd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.10", + "version": "1.4.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 0c4a0c64802..194f44ec03d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.10", + "version": "1.4.11", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 83d00c987a7..f52135c2067 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.10", + "version": "1.4.11", "publisher": "sst-dev", "repository": { "type": "git", From d5c4c26b4b1a62271c7278ffc81485577a46a8e4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 18:43:10 -0400 Subject: [PATCH 046/130] =?UTF-8?q?feat(server):=20auto-tag=20route=20span?= =?UTF-8?q?s=20with=20route=20params=20(session.id,=20message.id,=20?= =?UTF-8?q?=E2=80=A6)=20(#23189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/routes/instance/trace.ts | 35 +++++++++---- .../test/server/trace-attributes.test.ts | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/server/trace-attributes.test.ts diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index 3e1f72d8b24..fca313b745c 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -4,18 +4,31 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? R : never +// Build the base span attributes for an HTTP handler: method, path, and every +// matched route param (sessionID, messageID, partID, providerID, ptyID, …) +// prefixed with `opencode.`. This makes each request's root span searchable +// by ID in motel without having to parse the path string. +export interface RequestLike { + readonly req: { + readonly method: string + readonly url: string + param(): Record + } +} + +export function requestAttributes(c: RequestLike): Record { + const attributes: Record = { + "http.method": c.req.method, + "http.path": new URL(c.req.url).pathname, + } + for (const [key, value] of Object.entries(c.req.param())) { + attributes[`opencode.${key}`] = value + } + return attributes +} + 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, - }, - }), - ), - ) + return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) } export async function jsonRequest( diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts new file mode 100644 index 00000000000..376c81fc626 --- /dev/null +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { requestAttributes } from "../../src/server/routes/instance/trace" + +function fakeContext(method: string, url: string, params: Record) { + return { + req: { + method, + url, + param: () => params, + }, + } +} + +describe("requestAttributes", () => { + test("includes http method and path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) + expect(attrs["http.method"]).toBe("GET") + expect(attrs["http.path"]).toBe("/session") + }) + + test("strips query string from path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) + expect(attrs["http.path"]).toBe("/file/search") + }) + + test("tags route params with opencode. prefix", () => { + const attrs = requestAttributes( + fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { + sessionID: "ses_abc", + messageID: "msg_def", + partID: "prt_ghi", + }), + ) + expect(attrs["opencode.sessionID"]).toBe("ses_abc") + expect(attrs["opencode.messageID"]).toBe("msg_def") + expect(attrs["opencode.partID"]).toBe("prt_ghi") + }) + + test("produces no param attributes when no params are matched", () => { + const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) + expect(Object.keys(attrs).filter((k) => k.startsWith("opencode."))).toEqual([]) + }) + + test("handles non-ID params (e.g. mcp :name) without mangling", () => { + const attrs = requestAttributes( + fakeContext("POST", "http://localhost/mcp/exa/connect", { + name: "exa", + }), + ) + expect(attrs["opencode.name"]).toBe("exa") + }) +}) From b5aba5807cfbcafc57ffd488cbcb0148f8f1f4d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 18:47:48 -0400 Subject: [PATCH 047/130] feat(tui): show session ID in sidebar on non-prod channels (#23185) --- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4a7b711a032..6d92752efe3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" -import { InstallationVersion } from "@/installation/version" +import { InstallationChannel, InstallationVersion } from "@/installation/version" import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" @@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} + + {props.sessionID} + {" "} From 7b98f544ff6b58f8dde9421bd1e7bf3e8c395d4f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:10:34 -0400 Subject: [PATCH 048/130] feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) --- packages/opencode/src/util/effect-zod.ts | 16 ++++- .../opencode/test/util/effect-zod.test.ts | 69 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 771795ba682..22c6eda42d8 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -107,15 +107,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny { } function object(ast: SchemaAST.Objects): z.ZodTypeAny { + // Pure record: { [k: string]: V } if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { const sig = ast.indexSignatures[0] if (sig.parameter._tag !== "String") return fail(ast) return z.record(z.string(), walk(sig.type)) } - if (ast.indexSignatures.length > 0) return fail(ast) + // Pure object with known fields and no index signatures. + if (ast.indexSignatures.length === 0) { + return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + } - return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + // Struct with a catchall (StructWithRest): known fields + index signature. + // Only supports a single string-keyed index signature; multi-signature or + // symbol/number keys fall through to fail. + if (ast.indexSignatures.length !== 1) return fail(ast) + const sig = ast.indexSignatures[0] + if (sig.parameter._tag !== "String") return fail(ast) + return z + .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) + .catchall(walk(sig.type)) } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index ba67a60e6dc..89234e7265f 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -263,4 +263,73 @@ describe("util.effect-zod", () => { expect(result.error!.issues[0].message).toBe("missing 'required' key") }) }) + + describe("StructWithRest / catchall", () => { + test("struct with a string-keyed record rest parses known AND extra keys", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseURL: Schema.optional(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + + // Known fields come through as declared + expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" }) + + // Extra keys are preserved (catchall) + expect( + schema.parse({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }), + ).toEqual({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }) + }) + + test("catchall value type constrains the extras", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + count: Schema.Number, + }), + [Schema.Record(Schema.String, Schema.Number)], + ), + ) + + // Known field + numeric extras + expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 }) + + // Non-numeric extra is rejected + expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false) + }) + + test("JSON schema output marks additionalProperties appropriately", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + const shape = json(schema) as { additionalProperties?: unknown } + // Presence of `additionalProperties` (truthy or a schema) signals catchall. + expect(shape.additionalProperties).not.toBe(false) + expect(shape.additionalProperties).toBeDefined() + }) + + test("plain struct without rest still emits additionalProperties unchanged (regression)", () => { + const schema = zod(Schema.Struct({ id: Schema.String })) + expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) + }) + }) }) From eafbe5c57c41528b52974b671793896e1aa98e96 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:29:33 -0400 Subject: [PATCH 049/130] refactor(server): align route-span attrs with OTel semantic conventions (#23198) --- .../src/server/routes/instance/trace.ts | 21 ++++++++-- .../test/server/trace-attributes.test.ts | 38 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index fca313b745c..4c7119ef3ac 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -5,9 +5,12 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? R : never // Build the base span attributes for an HTTP handler: method, path, and every -// matched route param (sessionID, messageID, partID, providerID, ptyID, …) -// prefixed with `opencode.`. This makes each request's root span searchable -// by ID in motel without having to parse the path string. +// matched route param. Names follow OTel attribute-naming guidance: +// domain-first (`session.id`, `message.id`, …) so they match the existing +// OTel `session.id` semantic convention and the bare `message.id` we +// already emit from Tool.execute. Non-standard route params fall back to +// `opencode.` since those are internal implementation details +// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/). export interface RequestLike { readonly req: { readonly method: string @@ -16,13 +19,23 @@ export interface RequestLike { } } +// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`) +// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any +// other param is namespaced under `opencode.` to avoid colliding with +// standard conventions. +export function paramToAttributeKey(key: string): string { + const m = key.match(/^(.+)ID$/) + if (m) return `${m[1].toLowerCase()}.id` + return `opencode.${key}` +} + export function requestAttributes(c: RequestLike): Record { const attributes: Record = { "http.method": c.req.method, "http.path": new URL(c.req.url).pathname, } for (const [key, value] of Object.entries(c.req.param())) { - attributes[`opencode.${key}`] = value + attributes[paramToAttributeKey(key)] = value } return attributes } diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts index 376c81fc626..c6e8005a206 100644 --- a/packages/opencode/test/server/trace-attributes.test.ts +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { requestAttributes } from "../../src/server/routes/instance/trace" +import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" function fakeContext(method: string, url: string, params: Record) { return { @@ -11,6 +11,25 @@ function fakeContext(method: string, url: string, params: Record } } +describe("paramToAttributeKey", () => { + test("converts fooID to foo.id", () => { + expect(paramToAttributeKey("sessionID")).toBe("session.id") + expect(paramToAttributeKey("messageID")).toBe("message.id") + expect(paramToAttributeKey("partID")).toBe("part.id") + expect(paramToAttributeKey("projectID")).toBe("project.id") + expect(paramToAttributeKey("providerID")).toBe("provider.id") + expect(paramToAttributeKey("ptyID")).toBe("pty.id") + expect(paramToAttributeKey("permissionID")).toBe("permission.id") + expect(paramToAttributeKey("requestID")).toBe("request.id") + expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") + }) + + test("namespaces non-ID params under opencode.", () => { + expect(paramToAttributeKey("name")).toBe("opencode.name") + expect(paramToAttributeKey("slug")).toBe("opencode.slug") + }) +}) + describe("requestAttributes", () => { test("includes http method and path", () => { const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) @@ -23,7 +42,7 @@ describe("requestAttributes", () => { expect(attrs["http.path"]).toBe("/file/search") }) - test("tags route params with opencode. prefix", () => { + test("emits OTel-style .id for ID-shaped route params", () => { const attrs = requestAttributes( fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { sessionID: "ses_abc", @@ -31,22 +50,27 @@ describe("requestAttributes", () => { partID: "prt_ghi", }), ) - expect(attrs["opencode.sessionID"]).toBe("ses_abc") - expect(attrs["opencode.messageID"]).toBe("msg_def") - expect(attrs["opencode.partID"]).toBe("prt_ghi") + expect(attrs["session.id"]).toBe("ses_abc") + expect(attrs["message.id"]).toBe("msg_def") + expect(attrs["part.id"]).toBe("prt_ghi") + // No camelCase leftovers: + expect(attrs["opencode.sessionID"]).toBeUndefined() + expect(attrs["opencode.messageID"]).toBeUndefined() + expect(attrs["opencode.partID"]).toBeUndefined() }) test("produces no param attributes when no params are matched", () => { const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) - expect(Object.keys(attrs).filter((k) => k.startsWith("opencode."))).toEqual([]) + expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) }) - test("handles non-ID params (e.g. mcp :name) without mangling", () => { + test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { const attrs = requestAttributes( fakeContext("POST", "http://localhost/mcp/exa/connect", { name: "exa", }), ) expect(attrs["opencode.name"]).toBe("exa") + expect(attrs["name"]).toBeUndefined() }) }) From 2899984819f49b2e1119021d313f78caa0db0e2f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:29:53 -0400 Subject: [PATCH 050/130] refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/provider.ts | 220 +++++++++++------------ 2 files changed, 109 insertions(+), 113 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a2d62eaa5e6..bfb0c2f1f40 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -171,7 +171,7 @@ export const Info = z .optional() .describe("Agent configuration, see https://opencode.ai/docs/agents"), provider: z - .record(z.string(), ConfigProvider.Info) + .record(z.string(), ConfigProvider.Info.zod) .optional() .describe("Custom provider configurations and model overrides"), mcp: z diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 877677519f0..4664999de8c 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,120 +1,116 @@ +import { Schema } from "effect" import z from "zod" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Model = z - .object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), - z - .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }) - .strict(), - ]) - .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), +// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0). +const PositiveInt = Schema.Number.annotate({ + [ZodOverride]: z.number().int().positive(), +}) + +export const Model = Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + family: Schema.optional(Schema.String), + release_date: Schema.optional(Schema.String), + attachment: Schema.optional(Schema.Boolean), + reasoning: Schema.optional(Schema.Boolean), + temperature: Schema.optional(Schema.Boolean), + tool_call: Schema.optional(Schema.Boolean), + interleaved: Schema.optional( + Schema.Union([ + Schema.Literal(true), + Schema.Struct({ + field: Schema.Literals(["reasoning_content", "reasoning_details"]), + }), + ]), + ), + cost: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + context_over_200k: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + }), + ), }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), - variants: z - .record( - z.string(), - z - .object({ - disabled: z.boolean().optional().describe("Disable this variant for the model"), - }) - .catchall(z.any()), - ) - .optional() - .describe("Variant-specific configuration"), - }) - .partial() + ), + limit: Schema.optional( + Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: Schema.Number, + }), + ), + modalities: Schema.optional( + Schema.Struct({ + input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))), + output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))), + }), + ), + experimental: Schema.optional(Schema.Boolean), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + provider: Schema.optional(Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) })), + options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + variants: Schema.optional( + Schema.Record( + Schema.String, + Schema.StructWithRest( + Schema.Struct({ + disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ).annotate({ description: "Variant-specific configuration" }), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z - .object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - options: z - .object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), - setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), - timeout: z - .union([ - z - .number() - .int() - .positive() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - z.literal(false).describe("Disable timeout for this provider entirely."), - ]) - .optional() - .describe( +export class Info extends Schema.Class("ProviderConfig")({ + api: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + id: Schema.optional(Schema.String), + npm: Schema.optional(Schema.String), + whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + options: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseURL: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String).annotate({ + description: "GitHub Enterprise URL for copilot authentication", + }), + setCacheKey: Schema.optional(Schema.Boolean).annotate({ + description: "Enable promptCacheKey for this provider (default false)", + }), + timeout: Schema.optional( + Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({ + description: + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + }), + ).annotate({ + description: "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - chunkTimeout: z - .number() - .int() - .positive() - .optional() - .describe( + }), + chunkTimeout: Schema.optional(PositiveInt).annotate({ + description: "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - ), - }) - .catchall(z.any()) - .optional(), - models: z.record(z.string(), Model).optional(), - }) - .partial() - .strict() - .meta({ - ref: "ProviderConfig", - }) - -export type Info = z.infer + }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ), + models: Schema.optional(Schema.Record(Schema.String, Model)), +}) { + static readonly zod = zod(this) +} export * as ConfigProvider from "./provider" From cf0a53c501d069832be51640d1264cf658f381c9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 23:30:51 +0000 Subject: [PATCH 051/130] chore: generate --- packages/opencode/src/config/provider.ts | 4 +++- packages/sdk/openapi.json | 8 ++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 4664999de8c..b435f43759a 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -56,7 +56,9 @@ export const Model = Schema.Struct({ ), experimental: Schema.optional(Schema.Boolean), status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), - provider: Schema.optional(Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) })), + provider: Schema.optional( + Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), + ), options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), variants: Schema.optional( diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c3fd00356c2..5a93c4db2a8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11180,13 +11180,11 @@ "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, { - "description": "Disable timeout for this provider entirely.", "type": "boolean", "const": false } @@ -11247,8 +11245,7 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"], - "additionalProperties": false + "required": ["field"] } ] }, @@ -11377,8 +11374,7 @@ } } } - }, - "additionalProperties": false + } }, "McpLocalConfig": { "type": "object", From 211136e3a8cfdfcca658d97d5b0a1ce7b1b0a940 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:55:55 -0400 Subject: [PATCH 052/130] feat(effect-zod): transform support + walk memoization + flattened checks (#23203) --- packages/opencode/src/util/effect-zod.ts | 80 ++++++++-- .../opencode/test/util/effect-zod.test.ts | 148 +++++++++++++++++- 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 22c6eda42d8..6f46c684be4 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,4 +1,4 @@ -import { Schema, SchemaAST } from "effect" +import { Effect, Option, Schema, SchemaAST } from "effect" import z from "zod" /** @@ -8,33 +8,85 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") +// AST nodes are immutable and frequently shared across schemas (e.g. a single +// Schema.Class embedded in multiple parents). Memoizing by node identity +// avoids rebuilding equivalent Zod subtrees and keeps derived children stable +// by reference across callers. +const walkCache = new WeakMap() + +// Shared empty ParseOptions for the rare callers that need one — avoids +// allocating a fresh object per parse inside refinements and transforms. +const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const cached = walkCache.get(ast) + if (cached) return cached + const result = walkUncached(ast) + walkCache.set(ast, result) + return result +} + +function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined if (override) return override - let out = body(ast) - for (const check of ast.checks ?? []) { - out = applyCheck(out, check, ast) - } + // Schema.Class wraps its fields in a Declaration AST plus an encoding that + // constructs the class instance. For the Zod derivation we want the plain + // field shape (the decoded/consumer view), not the class instance — so + // Declarations fall through to body(), not encoded(). User-level + // Schema.decodeTo / Schema.transform attach encoding to non-Declaration + // nodes, where we do apply the transform. + const hasTransform = ast.encoding?.length && ast._tag !== "Declaration" + const base = hasTransform ? encoded(ast) : body(ast) + const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) - const next = desc ? out.describe(desc) : out - return ref ? next.meta({ ref }) : next + const described = desc ? out.describe(desc) : out + return ref ? described.meta({ ref }) : described } -function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check, ast: SchemaAST.AST): z.ZodTypeAny { - if (check._tag === "FilterGroup") { - return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out) +// Walk the encoded side and apply each link's decode to produce the decoded +// shape. A node `Target` produced by `from.decodeTo(Target)` carries +// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls +// nest the encoding via `Link.to` so walking it recursively threads all +// prior transforms — typical encoding.length is 1. +function encoded(ast: SchemaAST.AST): z.ZodTypeAny { + const encoding = ast.encoding! + return encoding.reduce((acc, link) => acc.transform((v) => decode(link.transformation, v)), walk(encoding[0].to)) +} + +// Transformations built via pure `SchemaGetter.transform(fn)` (the common +// decodeTo case) resolve synchronously, so running with no services is safe. +// Effectful / middleware-based transforms will surface as Effect defects. +function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { + const exit = Effect.runSyncExit( + (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect>, + ) + if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) + return Option.getOrElse(exit.value, () => value) +} + +// Flatten FilterGroups and any nested variants into a linear list of Filters +// so we can run all of them inside a single Zod .superRefine wrapper instead +// of stacking N wrapper layers (one per check). +function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { + const filters: SchemaAST.Filter[] = [] + const collect = (c: SchemaAST.Check) => { + if (c._tag === "FilterGroup") c.checks.forEach(collect) + else filters.push(c) } + checks.forEach(collect) return out.superRefine((value, ctx) => { - const issue = check.run(value, ast, {} as any) - if (!issue) return - const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed" - ctx.addIssue({ code: "custom", message }) + for (const filter of filters) { + const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) + if (!issue) continue + const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" + ctx.addIssue({ code: "custom", message }) + } }) } diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 89234e7265f..3d72984bfcb 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { Schema, SchemaGetter } from "effect" import z from "zod" import { zod, ZodOverride } from "../../src/util/effect-zod" @@ -332,4 +332,150 @@ describe("util.effect-zod", () => { expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) }) }) + + describe("transforms (Schema.decodeTo)", () => { + test("Number -> pseudo-Duration (seconds) applies the decode function", () => { + // Models the account/account.ts DurationFromSeconds pattern. + const SecondsToMs = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 1000), + encode: SchemaGetter.transform((ms: number) => ms / 1000), + }), + ) + + const schema = zod(SecondsToMs) + expect(schema.parse(3)).toBe(3000) + expect(schema.parse(0)).toBe(0) + }) + + test("String -> Number via parseInt decode", () => { + const ParsedInt = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + ) + + const schema = zod(ParsedInt) + expect(schema.parse("42")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("transform inside a struct field applies per-field", () => { + const Field = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n + 1), + encode: SchemaGetter.transform((n: number) => n - 1), + }), + ) + + const schema = zod( + Schema.Struct({ + plain: Schema.Number, + bumped: Field, + }), + ) + + expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 }) + }) + + test("chained decodeTo composes transforms in order", () => { + // String -> Number (parseInt) -> Number (doubled). + // Exercises the encoded() reduce, not just a single link. + const Chained = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 2), + encode: SchemaGetter.transform((n: number) => n / 2), + }), + ) + + const schema = zod(Chained) + expect(schema.parse("21")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => { + // Schema.Class uses Declaration + encoding under the hood to construct + // class instances. The walker must NOT apply that transform, or zod + // parsing would return class instances instead of plain objects. + class Method extends Schema.Class("TxTestMethod")({ + type: Schema.String, + value: Schema.Number, + }) {} + + const schema = zod(Method) + const parsed = schema.parse({ type: "oauth", value: 1 }) + expect(parsed).toEqual({ type: "oauth", value: 1 }) + // Guardrail: ensure we didn't get back a Method instance. + expect(parsed).not.toBeInstanceOf(Method) + }) + }) + + describe("optimizations", () => { + test("walk() memoizes by AST identity — same AST node returns same Zod", () => { + const shared = Schema.Struct({ id: Schema.String, name: Schema.String }) + const left = zod(shared) + const right = zod(shared) + expect(left).toBe(right) + }) + + test("nested reuse of the same AST reuses the cached Zod child", () => { + // Two different parents embed the same inner schema. The inner zod + // child should be identical by reference inside both parents. + class Inner extends Schema.Class("MemoTestInner")({ + value: Schema.String, + }) {} + + class OuterA extends Schema.Class("MemoTestOuterA")({ + inner: Inner, + }) {} + + class OuterB extends Schema.Class("MemoTestOuterB")({ + inner: Inner, + }) {} + + const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.() + const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.() + expect(shapeA.inner).toBe(shapeB.inner) + }) + + test("multiple checks run in a single refinement layer (all fire on one value)", () => { + // Three checks attached to the same schema. All three must run and + // report — asserting that no check silently got dropped when we + // flattened into one superRefine. + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(positive).check(even).check(under100)) + + const neg = schema.safeParse(-3) + expect(neg.success).toBe(false) + expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + + const big = schema.safeParse(101) + expect(big.success).toBe(false) + expect(big.error!.issues.map((i) => i.message)).toContain("too big") + + // Passing value satisfies all three + expect(schema.parse(42)).toBe(42) + }) + + test("FilterGroup flattens into the single refinement layer alongside its siblings", () => { + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const group = Schema.makeFilterGroup([positive, even]) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(group).check(under100)) + + const bad = schema.safeParse(-3) + expect(bad.success).toBe(false) + expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + }) + }) }) From c4816f944e6cbc1ff4c03484189c11f5fcd45e9c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 23:56:50 +0000 Subject: [PATCH 053/130] chore: generate --- packages/opencode/src/util/effect-zod.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 6f46c684be4..82c661e402a 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -56,7 +56,10 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { // prior transforms — typical encoding.length is 1. function encoded(ast: SchemaAST.AST): z.ZodTypeAny { const encoding = ast.encoding! - return encoding.reduce((acc, link) => acc.transform((v) => decode(link.transformation, v)), walk(encoding[0].to)) + return encoding.reduce( + (acc, link) => acc.transform((v) => decode(link.transformation, v)), + walk(encoding[0].to), + ) } // Transformations built via pure `SchemaGetter.transform(fn)` (the common @@ -64,7 +67,9 @@ function encoded(ast: SchemaAST.AST): z.ZodTypeAny { // Effectful / middleware-based transforms will surface as Effect defects. function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { const exit = Effect.runSyncExit( - (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect>, + (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< + Option.Option + >, ) if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) return Option.getOrElse(exit.value, () => value) From b493dabfe6edac3b671d9c369892a99d24650916 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:06:30 -0400 Subject: [PATCH 054/130] docs(effect): refresh migration status specs (#23206) --- packages/opencode/specs/effect/facades.md | 81 ++++++++----------- packages/opencode/specs/effect/http-api.md | 54 ++++++------- .../opencode/specs/effect/instance-context.md | 20 ++--- packages/opencode/specs/effect/loose-ends.md | 14 ++-- packages/opencode/specs/effect/migration.md | 78 ++++++++---------- packages/opencode/specs/effect/routes.md | 34 ++++---- .../opencode/specs/effect/server-package.md | 36 +++++---- packages/opencode/specs/effect/tools.md | 8 +- 8 files changed, 147 insertions(+), 178 deletions(-) diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md index e2d9d3d8a1a..8bf7d97badc 100644 --- a/packages/opencode/specs/effect/facades.md +++ b/packages/opencode/specs/effect/facades.md @@ -1,12 +1,13 @@ # Facade removal checklist -Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`. +Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`. -As of 2026-04-13, latest `origin/dev`: +Current status on this branch: -- `src/` still has 15 `makeRuntime(...)` call sites. -- 13 of those are still in scope for facade removal. -- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`. +- `src/` has 5 `makeRuntime(...)` call sites total. +- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`. +- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`. +- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`. Recent progress: @@ -15,8 +16,9 @@ Recent progress: ## Priority hotspots -- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades. -- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion. +- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`. +- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`. +- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`. ## Completed Batches @@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc 5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline. 6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code. -## Next batch +## Remaining work -Recommended next five, in order: +Most of the original facade-removal backlog is already done. The practical remaining work is narrower now: -1. `src/permission/index.ts` -2. `src/agent/agent.ts` -3. `src/session/summary.ts` -4. `src/session/revert.ts` -5. `src/mcp/auth.ts` - -Why this batch: - -- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`. -- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`. -- `McpAuth` is small and closely related to the just-landed `MCP` cleanup. - -After that batch, the expected follow-up is the main session cluster: - -1. `src/session/index.ts` -2. `src/session/prompt.ts` -3. `src/session/compaction.ts` +1. remove the `Npm` runtime-backed facade from `src/npm/index.ts` +2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts` +3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist ## Checklist -- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts` -- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts` -- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts` -- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts` -- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts` -- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts` -- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts` -- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts` -- [x] `src/file/index.ts` (`File`) - facades removed and merged. -- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged. -- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged. -- [x] `src/config/config.ts` (`Config`) - facades removed and merged. -- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged. -- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged. -- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged. -- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged. -- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged. -- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts` -- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged. -- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts` -- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts` -- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts` -- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts` +- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service` +- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service` +- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed +- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed +- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed +- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed +- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed +- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed +- [x] `src/file/index.ts` (`File`) - facades removed and merged +- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged +- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged +- [x] `src/config/config.ts` (`Config`) - facades removed and merged +- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged +- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged +- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged +- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged +- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged +- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged ## Excluded `makeRuntime(...)` sites diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 09cb86a0005..93ef81a3252 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex ### Mixed handler styles -Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first. +Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first. ### Non-JSON routes @@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda ### 1. Finish the prerequisites first -- continue route-handler effectification in `server/instance/*.ts` +- continue route-handler effectification in `server/routes/instance/*.ts` - continue schema migration toward Effect Schema-first DTOs and errors - keep removing service facades @@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in: -- `server/instance/question.ts` -- `server/instance/provider.ts` -- `server/instance/permission.ts` +- `server/routes/instance/question.ts` +- `server/routes/instance/provider.ts` +- `server/routes/instance/permission.ts` Avoid `session.ts`, SSE, websocket, and TUI-facing routes first. @@ -155,9 +155,9 @@ This gives: As each route group is ported to `HttpApi`: -1. change its `root` path from `/experimental/httpapi/` to `/` -2. add `.all("/", handler)` / `.all("//*", handler)` to the flag block in `instance/index.ts` -3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path +1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts` +2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path +3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes 4. verify SDK output is unchanged Leave streaming-style endpoints on Hono until there is a clear reason to move them. @@ -267,7 +267,7 @@ Use the same sequence for each route group. 3. Apply the schema migration ordering above so those types are Effect Schema-first. 4. Define the `HttpApi` contract separately from the handlers. 5. Implement handlers by yielding the existing service from context. -6. Mount the new surface in parallel under an experimental prefix. +6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge. 7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above). 8. Add one end-to-end test and one OpenAPI-focused test. 9. Compare ergonomics before migrating the next endpoint. @@ -286,20 +286,20 @@ Placement rule: - keep `HttpApi` code under `src/server`, not `src/effect` - `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing - place each `HttpApi` slice next to the HTTP boundary it serves -- for instance-scoped routes, prefer `src/server/instance/httpapi/*` -- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*` +- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*` +- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*` Suggested file layout for a repeatable spike: -- `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 +- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group +- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups +- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch Suggested responsibilities: - `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 +- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup) +- tests should verify the bridged routes through the normal server surface ## Example migration shape @@ -319,33 +319,33 @@ Each route-group spike should follow the same shape. - keep handler bodies thin - keep transport mapping at the HTTP boundary only -### 3. Standalone server +### 3. Bridged server -- 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 +- the Effect HTTP layer is composed in `httpapi/server.ts` +- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)` +- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag +- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works ### 4. Verification - seed real state through the existing service -- call the experimental endpoints +- call the bridged endpoints with the flag enabled - assert that the service behavior is unchanged - assert that the generated OpenAPI contains the migrated paths and schemas ## Boundary composition -The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server. +The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server. ### Auth -- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic` +- the bridged `HttpApi` layer 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 +- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice ### Instance and workspace lookup -- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params +- the bridged `HttpApi` layer 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 @@ -360,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi The first slice is successful if: -- the standalone Effect server starts and serves the endpoints independently of the Hono server +- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled - 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 diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 6c160a94771..7d0d7eb13c8 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of: - `Instance.reload(...)` - `Instance.dispose()` / `Instance.disposeAll()` -Current total: `54` files in `packages/opencode/src`. +Current total: `56` files in `packages/opencode/src`. ### Core bridge and plumbing @@ -177,13 +177,13 @@ Migration rule: 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` +- `src/server/routes/instance/middleware.ts` +- `src/server/routes/instance/index.ts` +- `src/server/routes/instance/project.ts` +- `src/server/routes/control/workspace.ts` +- `src/server/routes/instance/file.ts` +- `src/server/routes/instance/experimental.ts` +- `src/server/routes/global.ts` Migration rule: @@ -239,7 +239,7 @@ Migration rule: 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/cli/cmd/tui/config/tui-migrate.ts` - `src/file/index.ts` - `src/file/watcher.ts` - `src/format/formatter.ts` @@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are - `src/project/vcs.ts` - `src/provider/provider.ts` - `src/pty/index.ts` -- `src/session/index.ts` +- `src/session/session.ts` - `src/session/instruction.ts` - `src/session/llm.ts` - `src/session/system.ts` diff --git a/packages/opencode/specs/effect/loose-ends.md b/packages/opencode/specs/effect/loose-ends.md index a2fed492b33..4e7ada7ff91 100644 --- a/packages/opencode/specs/effect/loose-ends.md +++ b/packages/opencode/specs/effect/loose-ends.md @@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc ## Config / TUI -- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal. +- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration. Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`. -- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code. +- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code. Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`. -- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted. +- [x] `env/index.ts` - already uses `InstanceState.make(...)`. ## ConfigPaths @@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc - `readFile(...)` - `parseText(...)` - [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists. -- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists. -- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands. +- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists. +- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands. ## Instance cleanup -- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated. -- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone. -- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal. +- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over. ## Notes diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index b8bf4e0494f..947eef5a156 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise ## Service shape -Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions: +Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module. ```ts -export namespace Foo { - export interface Interface { - readonly get: (id: FooID) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Foo") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - // For instance-scoped services: - const state = yield* InstanceState.make( - Effect.fn("Foo.state")(() => Effect.succeed({ ... })), - ) - - const get = Effect.fn("Foo.get")(function* (id: FooID) { - const s = yield* InstanceState.get(state) - // ... - }) - - return Service.of({ get }) - }), - ) - - // Optional: wire dependencies - export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) - - // Per-service runtime (inside the namespace) - const { runPromise } = makeRuntime(Service, defaultLayer) - - // Async facade functions - export async function get(id: FooID) { - return runPromise((svc) => svc.get(id)) - } +export interface Interface { + readonly get: (id: FooID) => Effect.Effect } + +export class Service extends Context.Service()("@opencode/Foo") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Foo.state")(() => Effect.succeed({ ... })), + ) + + const get = Effect.fn("Foo.get")(function* (id: FooID) { + const s = yield* InstanceState.get(state) + // ... + }) + + return Service.of({ get }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) + +export * as Foo from "." ``` Rules: -- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split -- `runPromise` goes inside the namespace (not exported unless tests need it) -- Facade functions are plain `async function` — no `fn()` wrappers -- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing) -- No `Layer.fresh` — InstanceState handles per-directory isolation +- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }` +- Use `Effect.fn("Foo.method")` for Effect methods +- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection +- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase +- No `Layer.fresh` for normal per-directory isolation; use `InstanceState` ## Schema → Zod interop @@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`. ## Destroying the facades -This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`. +This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`. These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. @@ -297,11 +287,11 @@ For each service, the migration is roughly: - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime. - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration. - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed. -- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed. -- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. +- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed. +- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. -- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed. +- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed. - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. ## Route handler effectification diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md index f6a61d23424..3bf7e1b5561 100644 --- a/packages/opencode/specs/effect/routes.md +++ b/packages/opencode/specs/effect/routes.md @@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu ## Current route files -Current instance route files live under `src/server/instance`, not `server/routes`. +Current instance route files live under `src/server/routes/instance`. -The main migration targets are: +Files that are already mostly on the intended service-yielding shape: -- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus -- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions -- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider -- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style -- [ ] `server/instance/pty.ts` — still calls Pty facades directly -- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades +- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service` +- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service` +- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service` +- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service` +- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service` -Additional route files that still participate in the migration: +Files still worth tracking here: -- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format -- [ ] `server/instance/file.ts` — Ripgrep, File, LSP -- [ ] `server/instance/mcp.ts` — MCP facade-heavy -- [ ] `server/instance/permission.ts` — Permission -- [ ] `server/instance/workspace.ts` — Workspace -- [ ] `server/instance/tui.ts` — Bus and Session -- [ ] `server/instance/middleware.ts` — Session and Workspace lookups +- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage +- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path` +- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed +- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads +- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)` +- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style ## Notes -- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style. -- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly. +- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files. +- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files. diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md index 10be7b9aeda..06e89c18de5 100644 --- a/packages/opencode/specs/effect/server-package.md +++ b/packages/opencode/specs/effect/server-package.md @@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`. Important current facts: - there is no `packages/core` or `packages/cli` workspace yet -- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet +- there is no `packages/server` workspace yet on this branch - the main host server is still Hono-based in `src/server/server.ts` - current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts` - the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts` -- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts` -- that experimental slice is mounted under `/experimental/httpapi/question` -- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts` +- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*` +- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI` +- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes This means the package split should start from an extraction path, not from greenfield package ownership. @@ -209,17 +209,19 @@ Current host and route composition: - `src/server/server.ts` - `src/server/control/index.ts` -- `src/server/instance/index.ts` +- `src/server/routes/instance/index.ts` - `src/server/middleware.ts` - `src/server/adapter.bun.ts` - `src/server/adapter.node.ts` -Current experimental `HttpApi` slice: +Current bridged `HttpApi` slices: -- `src/server/instance/httpapi/question.ts` -- `src/server/instance/httpapi/index.ts` -- `src/server/instance/experimental.ts` -- `test/server/question-httpapi.test.ts` +- `src/server/routes/instance/httpapi/question.ts` +- `src/server/routes/instance/httpapi/permission.ts` +- `src/server/routes/instance/httpapi/provider.ts` +- `src/server/routes/instance/httpapi/config.ts` +- `src/server/routes/instance/httpapi/project.ts` +- `src/server/routes/instance/httpapi/server.ts` Current OpenAPI flow: @@ -245,7 +247,7 @@ Keep in `packages/opencode` for now: - `src/server/server.ts` - `src/server/control/index.ts` -- `src/server/instance/*.ts` +- `src/server/routes/**/*.ts` - `src/server/middleware.ts` - `src/server/adapter.*.ts` - `src/effect/app-runtime.ts` @@ -305,14 +307,13 @@ Bad early migration targets: ## First vertical slice -The first slice for the package split is the existing experimental `question` group. +The first slice for the package split is still the existing `question` `HttpApi` group. Why `question` first: - it already exists as an experimental `HttpApi` slice - it already follows the desired contract and implementation split in one file - it is already mounted through the current Hono host -- it already has an end-to-end test - it is JSON-only - it has low blast radius @@ -357,7 +358,7 @@ Done means: Scope: -- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts` +- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts` - place it in `packages/server/src/definition/question.ts` - aggregate it in `packages/server/src/definition/api.ts` - generate OpenAPI in `packages/server/src/openapi.ts` @@ -399,8 +400,9 @@ Scope: - replace local experimental question route wiring in `packages/opencode` - keep the same mount path: -- `/experimental/httpapi/question` -- `/experimental/httpapi/question/doc` +- `/question` +- `/question/:requestID/reply` +- `/question/:requestID/reject` Rules: @@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing. Typical validation for the first waves: - `bun typecheck` in the touched package directory or directories -- the relevant route test, especially `test/server/question-httpapi.test.ts` +- the relevant server / route coverage for the migrated slice - merged OpenAPI coverage if the PR touches spec generation Do not run tests from repo root. diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index e97e0d23e0c..7b47831709a 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow ## Exported tools -These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path: +These exported tool definitions currently use `Tool.define(...)` in `src/tool`: - [x] `apply_patch.ts` - [x] `bash.ts` @@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre - [x] `glob.ts` - [x] `grep.ts` - [x] `invalid.ts` -- [x] `ls.ts` - [x] `lsp.ts` - [x] `multiedit.ts` - [x] `plan.ts` @@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre Notes: -- `batch.ts` is no longer a current tool file and should not be tracked here. +- There is no current `ls.ts` tool file on this branch. - `truncate.ts` is an Effect service used by tools, not a tool definition itself. - `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions. @@ -73,7 +72,7 @@ Current spot cleanups worth tracking: - [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection - [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up - [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction -- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts` +- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes - [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application Notable items that are already effectively on the target path and do not need separate migration bullets right now: @@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se - `write.ts` - `codesearch.ts` - `websearch.ts` -- `ls.ts` - `multiedit.ts` - `edit.ts` From 05cdb7c1071a2ba900ba14449085d261755ff3e0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:10:20 -0400 Subject: [PATCH 055/130] refactor(v2): tag session unions and exhaustively match events (#23201) --- packages/opencode/src/v2/session-entry.ts | 159 +++++++++--------- packages/opencode/src/v2/session-event.ts | 2 +- .../test/session/session-entry.test.ts | 61 ++++++- 3 files changed, 140 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 140fa47d234..08122428aeb 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { SessionEvent } from "./session-event" -import { produce } from "immer" +import { castDraft, produce } from "immer" export const ID = SessionEvent.ID export type ID = Schema.Schema.Type @@ -70,7 +70,9 @@ export class ToolStateError extends Schema.Class("Session.Entry. metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) export type ToolState = Schema.Schema.Type export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ @@ -96,7 +98,9 @@ export class AssistantReasoning extends Schema.Class("Sessio text: Schema.String, }) {} -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]) +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) export type AssistantContent = Schema.Schema.Type export class Assistant extends Schema.Class("Session.Entry.Assistant")({ @@ -126,7 +130,7 @@ export class Compaction extends Schema.Class("Session.Entry.Compacti ...Base, }) {} -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]) +export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) export type Entry = Schema.Schema.Type @@ -141,19 +145,29 @@ export function step(old: History, event: SessionEvent.Event): History { return produce(old, (draft) => { const lastAssistant = draft.entries.findLast((x) => x.type === "assistant") const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined + type DraftContent = NonNullable["content"][number] + type DraftTool = Extract - switch (event.type) { - case "prompt": { + const latestTool = (callID?: string) => + pendingAssistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), + ) + const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text") + const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning") + + SessionEvent.Event.match(event, { + prompt: (event) => { + const entry = User.fromEvent(event) if (pendingAssistant) { - // @ts-expect-error - draft.pending.push(User.fromEvent(event)) - break + draft.pending.push(castDraft(entry)) + return } - // @ts-expect-error - draft.entries.push(User.fromEvent(event)) - break - } - case "step.started": { + draft.entries.push(castDraft(entry)) + }, + synthetic: (event) => { + draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } })) + }, + "step.started": (event) => { if (pendingAssistant) pendingAssistant.time.completed = event.timestamp draft.entries.push({ id: event.id, @@ -163,27 +177,28 @@ export function step(old: History, event: SessionEvent.Event): History { }, content: [], }) - break - } - case "text.started": { - if (!pendingAssistant) break + }, + "step.ended": (event) => { + if (!pendingAssistant) return + pendingAssistant.time.completed = event.timestamp + pendingAssistant.cost = event.cost + pendingAssistant.tokens = event.tokens + }, + "text.started": () => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "text", text: "", }) - break - } - case "text.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "text") + }, + "text.delta": (event) => { + if (!pendingAssistant) return + const match = latestText() if (match) match.text += event.delta - break - } - case "text.ended": { - break - } - case "tool.input.started": { - if (!pendingAssistant) break + }, + "text.ended": () => {}, + "tool.input.started": (event) => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "tool", callID: event.callID, @@ -196,21 +211,17 @@ export function step(old: History, event: SessionEvent.Event): History { input: "", }, }) - break - } - case "tool.input.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.input.delta": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) if (match) match.state.input += event.delta - break - } - case "tool.input.ended": { - break - } - case "tool.called": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.input.ended": () => {}, + "tool.called": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match) { match.time.ran = event.timestamp match.state = { @@ -218,11 +229,10 @@ export function step(old: History, event: SessionEvent.Event): History { input: event.input, } } - break - } - case "tool.success": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.success": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match && match.state.status === "running") { match.state = { status: "completed", @@ -230,15 +240,13 @@ export function step(old: History, event: SessionEvent.Event): History { output: event.output ?? "", title: event.title, metadata: event.metadata ?? {}, - // @ts-expect-error - attachments: event.attachments ?? [], + attachments: [...(event.attachments ?? [])], } } - break - } - case "tool.error": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.error": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match && match.state.status === "running") { match.state = { status: "error", @@ -247,36 +255,29 @@ export function step(old: History, event: SessionEvent.Event): History { metadata: event.metadata ?? {}, } } - break - } - case "reasoning.started": { - if (!pendingAssistant) break + }, + "reasoning.started": () => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "reasoning", text: "", }) - break - } - case "reasoning.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") + }, + "reasoning.delta": (event) => { + if (!pendingAssistant) return + const match = latestReasoning() if (match) match.text += event.delta - break - } - case "reasoning.ended": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") + }, + "reasoning.ended": (event) => { + if (!pendingAssistant) return + const match = latestReasoning() if (match) match.text = event.text - break - } - case "step.ended": { - if (!pendingAssistant) break - pendingAssistant.time.completed = event.timestamp - pendingAssistant.cost = event.cost - pendingAssistant.tokens = event.tokens - break - } - } + }, + retried: () => {}, + compacted: (event) => { + draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } })) + }, + }) }) } diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 8ea239033f0..11d4a5db2d3 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -441,7 +441,7 @@ export namespace SessionEvent { { mode: "oneOf", }, - ) + ).pipe(Schema.toTaggedUnion("type")) export type Event = Schema.Schema.Type export type Type = Event["type"] } diff --git a/packages/opencode/test/session/session-entry.test.ts b/packages/opencode/test/session/session-entry.test.ts index 7eba3900d79..dea8da20a0c 100644 --- a/packages/opencode/test/session/session-entry.test.ts +++ b/packages/opencode/test/session/session-entry.test.ts @@ -591,7 +591,64 @@ describe("session-entry step", () => { ) }) - test.failing("records synthetic events", () => { + test("routes tool events by callID when tool streams interleave", () => { + FastCheck.assert( + FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), + SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), + SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), + SessionEvent.Tool.Called.create({ + callID: "a", + tool: "bash", + input: a, + provider: { executed: true }, + timestamp: time(5), + }), + SessionEvent.Tool.Called.create({ + callID: "b", + tool: "grep", + input: b, + provider: { executed: true }, + timestamp: time(6), + }), + SessionEvent.Tool.Success.create({ + callID: "a", + title: titleA, + output: "done-a", + provider: { executed: true }, + timestamp: time(7), + }), + SessionEvent.Tool.Success.create({ + callID: "b", + title: titleB, + output: "done-b", + provider: { executed: true }, + timestamp: time(8), + }), + ], + active(), + ) + + const first = tool(next, "a") + const second = tool(next, "b") + + expect(first?.state.status).toBe("completed") + expect(second?.state.status).toBe("completed") + if (first?.state.status !== "completed" || second?.state.status !== "completed") return + + expect(first.state.input).toEqual(a) + expect(second.state.input).toEqual(b) + expect(first.state.title).toBe(titleA) + expect(second.state.title).toBe(titleB) + }), + { numRuns: 50 }, + ) + }) + + test("records synthetic events", () => { FastCheck.assert( FastCheck.property(word, (body) => { const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) })) @@ -604,7 +661,7 @@ describe("session-entry step", () => { ) }) - test.failing("records compaction events", () => { + test("records compaction events", () => { FastCheck.assert( FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { const next = SessionEntry.step( From bb90f3bbf99e00c2b5d780b38da318cfa3fd4c72 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:50:36 -0400 Subject: [PATCH 056/130] feat(effect-zod): translate well-known filters into native Zod methods (#23209) --- packages/opencode/src/util/effect-zod.ts | 89 +++++++- .../opencode/test/util/effect-zod.test.ts | 191 ++++++++++++++++++ 2 files changed, 275 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 82c661e402a..227a708442e 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -75,9 +75,12 @@ function decode(transformation: SchemaAST.Link["transformation"], value: unknown return Option.getOrElse(exit.value, () => value) } -// Flatten FilterGroups and any nested variants into a linear list of Filters -// so we can run all of them inside a single Zod .superRefine wrapper instead -// of stacking N wrapper layers (one per check). +// Flatten FilterGroups and any nested variants into a linear list of Filters. +// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are +// translated into native Zod methods so their JSON Schema output includes +// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …). +// Anything else falls back to a single .superRefine layer — runtime-only, +// emits no JSON Schema constraint. function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { const filters: SchemaAST.Filter[] = [] const collect = (c: SchemaAST.Check) => { @@ -85,8 +88,19 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST else filters.push(c) } checks.forEach(collect) - return out.superRefine((value, ctx) => { - for (const filter of filters) { + + const unhandled: SchemaAST.Filter[] = [] + const translated = filters.reduce((acc, filter) => { + const next = translateFilter(acc, filter) + if (next) return next + unhandled.push(filter) + return acc + }, out) + + if (unhandled.length === 0) return translated + + return translated.superRefine((value, ctx) => { + for (const filter of unhandled) { const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) if (!issue) continue const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" @@ -95,6 +109,71 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST }) } +// Translate a well-known Effect Schema filter into a native Zod method call on +// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every +// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at +// construction time. Returns `undefined` for unrecognised filters so the +// caller can fall back to the generic .superRefine path. +function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter): z.ZodTypeAny | undefined { + const meta = (filter.annotations as { meta?: Record } | undefined)?.meta + if (!meta || typeof meta._tag !== "string") return undefined + switch (meta._tag) { + case "isInt": + return call(out, "int") + case "isFinite": + return call(out, "finite") + case "isGreaterThan": + return call(out, "gt", meta.exclusiveMinimum) + case "isGreaterThanOrEqualTo": + return call(out, "gte", meta.minimum) + case "isLessThan": + return call(out, "lt", meta.exclusiveMaximum) + case "isLessThanOrEqualTo": + return call(out, "lte", meta.maximum) + case "isBetween": { + const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum) + if (!lo) return undefined + return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum) + } + case "isMultipleOf": + return call(out, "multipleOf", meta.divisor) + case "isMinLength": + return call(out, "min", meta.minLength) + case "isMaxLength": + return call(out, "max", meta.maxLength) + case "isLengthBetween": { + const lo = call(out, "min", meta.minimum) + if (!lo) return undefined + return call(lo, "max", meta.maximum) + } + case "isPattern": + return call(out, "regex", meta.regExp) + case "isStartsWith": + return call(out, "startsWith", meta.startsWith) + case "isEndsWith": + return call(out, "endsWith", meta.endsWith) + case "isIncludes": + return call(out, "includes", meta.includes) + case "isUUID": + return call(out, "uuid") + case "isULID": + return call(out, "ulid") + case "isBase64": + return call(out, "base64") + case "isBase64Url": + return call(out, "base64url") + } + return undefined +} + +// Invoke a named Zod method on `target` if it exists, otherwise return +// undefined so the caller can fall back. Using this helper instead of a +// typed cast keeps `translateFilter` free of per-case narrowing noise. +function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined { + const fn = (target as unknown as Record z.ZodTypeAny) | undefined>)[method] + return typeof fn === "function" ? fn.apply(target, args) : undefined +} + function issueMessage(issue: any): string | undefined { if (typeof issue?.annotations?.message === "string") return issue.annotations.message if (typeof issue?.message === "string") return issue.message diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 3d72984bfcb..1d999e979d5 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -478,4 +478,195 @@ describe("util.effect-zod", () => { expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) }) }) + + describe("well-known refinement translation", () => { + test("Schema.isInt emits type: integer in JSON Schema", () => { + const schema = zod(Schema.Number.check(Schema.isInt())) + const native = json(z.number().int()) + expect(json(schema)).toEqual(native) + expect(schema.parse(3)).toBe(3) + expect(schema.safeParse(1.5).success).toBe(false) + }) + + test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => { + const schema = zod(Schema.Number.check(Schema.isGreaterThan(0))) + expect((json(schema) as any).exclusiveMinimum).toBe(0) + expect(schema.parse(1)).toBe(1) + expect(schema.safeParse(0).success).toBe(false) + expect(schema.safeParse(-1).success).toBe(false) + }) + + test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => { + const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))) + expect((json(schema) as any).minimum).toBe(0) + expect(schema.parse(0)).toBe(0) + expect(schema.safeParse(-1).success).toBe(false) + }) + + test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => { + const schema = zod(Schema.Number.check(Schema.isLessThan(10))) + expect((json(schema) as any).exclusiveMaximum).toBe(10) + expect(schema.parse(9)).toBe(9) + expect(schema.safeParse(10).success).toBe(false) + }) + + test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => { + const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10))) + expect((json(schema) as any).maximum).toBe(10) + expect(schema.parse(10)).toBe(10) + expect(schema.safeParse(11).success).toBe(false) + }) + + test("Schema.isMultipleOf(5) emits multipleOf: 5", () => { + const schema = zod(Schema.Number.check(Schema.isMultipleOf(5))) + expect((json(schema) as any).multipleOf).toBe(5) + expect(schema.parse(10)).toBe(10) + expect(schema.safeParse(7).success).toBe(false) + }) + + test("Schema.isFinite validates at runtime", () => { + const schema = zod(Schema.Number.check(Schema.isFinite())) + expect(schema.parse(1)).toBe(1) + expect(schema.safeParse(Infinity).success).toBe(false) + expect(schema.safeParse(NaN).success).toBe(false) + }) + + test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => { + const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) + const native = json(z.number().int().positive()) + expect(json(schema)).toEqual(native) + expect(schema.parse(3)).toBe(3) + expect(schema.safeParse(0).success).toBe(false) + expect(schema.safeParse(1.5).success).toBe(false) + }) + + test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => { + const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) + const native = json(z.number().int().min(0)) + expect(json(schema)).toEqual(native) + expect(schema.parse(0)).toBe(0) + expect(schema.safeParse(-1).success).toBe(false) + }) + + test("Schema.isBetween emits both bounds", () => { + const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 }))) + const shape = json(schema) as any + expect(shape.minimum).toBe(1) + expect(shape.maximum).toBe(10) + expect(schema.parse(5)).toBe(5) + expect(schema.safeParse(11).success).toBe(false) + expect(schema.safeParse(0).success).toBe(false) + }) + + test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => { + const schema = zod( + Schema.Number.check( + Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }), + ), + ) + const shape = json(schema) as any + expect(shape.exclusiveMinimum).toBe(1) + expect(shape.exclusiveMaximum).toBe(10) + expect(schema.parse(5)).toBe(5) + expect(schema.safeParse(1).success).toBe(false) + expect(schema.safeParse(10).success).toBe(false) + }) + + test("Schema.isInt32 (FilterGroup) produces integer bounds", () => { + const schema = zod(Schema.Number.check(Schema.isInt32())) + const shape = json(schema) as any + expect(shape.type).toBe("integer") + expect(shape.minimum).toBe(-2147483648) + expect(shape.maximum).toBe(2147483647) + expect(schema.parse(42)).toBe(42) + expect(schema.safeParse(1.5).success).toBe(false) + expect(schema.safeParse(2147483648).success).toBe(false) + }) + + test("Schema.isMinLength on string emits minLength", () => { + const schema = zod(Schema.String.check(Schema.isMinLength(3))) + expect((json(schema) as any).minLength).toBe(3) + expect(schema.parse("abc")).toBe("abc") + expect(schema.safeParse("ab").success).toBe(false) + }) + + test("Schema.isMaxLength on string emits maxLength", () => { + const schema = zod(Schema.String.check(Schema.isMaxLength(5))) + expect((json(schema) as any).maxLength).toBe(5) + expect(schema.parse("abcde")).toBe("abcde") + expect(schema.safeParse("abcdef").success).toBe(false) + }) + + test("Schema.isLengthBetween on string emits both bounds", () => { + const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4))) + const shape = json(schema) as any + expect(shape.minLength).toBe(2) + expect(shape.maxLength).toBe(4) + expect(schema.parse("abc")).toBe("abc") + expect(schema.safeParse("a").success).toBe(false) + expect(schema.safeParse("abcde").success).toBe(false) + }) + + test("Schema.isMinLength on array emits minItems", () => { + const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1))) + expect((json(schema) as any).minItems).toBe(1) + expect(schema.parse(["x"])).toEqual(["x"]) + expect(schema.safeParse([]).success).toBe(false) + }) + + test("Schema.isPattern emits pattern", () => { + const schema = zod(Schema.String.check(Schema.isPattern(/^per/))) + expect((json(schema) as any).pattern).toBe("^per") + expect(schema.parse("per_abc")).toBe("per_abc") + expect(schema.safeParse("abc").success).toBe(false) + }) + + test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => { + const schema = zod(Schema.String.check(Schema.isStartsWith("per"))) + const native = json(z.string().startsWith("per")) + expect(json(schema)).toEqual(native) + expect(schema.parse("per_abc")).toBe("per_abc") + expect(schema.safeParse("abc").success).toBe(false) + }) + + test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => { + const schema = zod(Schema.String.check(Schema.isEndsWith(".json"))) + const native = json(z.string().endsWith(".json")) + expect(json(schema)).toEqual(native) + expect(schema.parse("a.json")).toBe("a.json") + expect(schema.safeParse("a.txt").success).toBe(false) + }) + + test("Schema.isUUID emits format: uuid", () => { + const schema = zod(Schema.String.check(Schema.isUUID())) + expect((json(schema) as any).format).toBe("uuid") + }) + + test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => { + // isInt is well-known (translates to .int()); the anonymous filter falls + // back to superRefine. + const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed")) + const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven)) + + const shape = json(schema) as any + // Well-known translation is preserved — type is integer, not plain number + expect(shape.type).toBe("integer") + + // Runtime: both constraints fire + expect(schema.parse(3)).toBe(3) + expect(schema.safeParse(1.5).success).toBe(false) + const seven = schema.safeParse(7) + expect(seven.success).toBe(false) + expect(seven.error!.issues[0].message).toBe("no sevens allowed") + }) + + test("inside a struct field, well-known refinements propagate through", () => { + // Mirrors config.ts port: z.number().int().positive().optional() + const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) + const schema = zod(Schema.Struct({ port: Port })) + const shape = json(schema) as any + expect(shape.properties.port.type).toBe("integer") + expect(shape.properties.port.exclusiveMinimum).toBe(0) + }) + }) }) From 36119ff1731b3bc94ed0cffcb32b05987a48b088 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:55:38 -0400 Subject: [PATCH 057/130] feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207) --- packages/opencode/src/util/effect-zod.ts | 48 ++++++++-- .../opencode/test/util/effect-zod.test.ts | 87 ++++++++++++++++++- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 227a708442e..cdcd99c9761 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -40,7 +40,12 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { // Declarations fall through to body(), not encoded(). User-level // Schema.decodeTo / Schema.transform attach encoding to non-Declaration // nodes, where we do apply the transform. - const hasTransform = ast.encoding?.length && ast._tag !== "Declaration" + // + // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` + // on the inner Zod rather than a transform wrapper — so optional ASTs whose + // encoding resolves a default from Option.none() route through body()/opt(). + const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base const desc = SchemaAST.resolveDescription(ast) @@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny { function opt(ast: SchemaAST.AST): z.ZodTypeAny { if (ast._tag !== "Union") return fail(ast) const items = ast.types.filter((item) => item._tag !== "Undefined") - if (items.length === 1) return walk(items[0]).optional() - if (items.length > 1) - return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]).optional() - return z.undefined().optional() + const inner = + items.length === 1 + ? walk(items[0]) + : items.length > 1 + ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) + : z.undefined() + // Schema.withDecodingDefault attaches an encoding `Link` whose transformation + // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke + // it to extract the default and emit `.default(...)` instead of `.optional()`. + const fallback = extractDefault(ast) + if (fallback !== undefined) return inner.default(fallback.value) + return inner.optional() +} + +type DecodeLink = { + readonly transformation: { + readonly decode: { + readonly run: ( + input: Option.Option, + options: SchemaAST.ParseOptions, + ) => Effect.Effect, unknown> + } + } +} + +function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { + const encoding = (ast as { encoding?: ReadonlyArray }).encoding + if (!encoding?.length) return undefined + // Walk the chain of encoding Links in order; the first Getter that produces + // a value from Option.none wins. withDecodingDefault always puts its + // defaulting Link adjacent to the optional Union. + for (const link of encoding) { + const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) + if (probe._tag !== "Success") continue + if (Option.isSome(probe.value)) return { value: probe.value.value } + } + return undefined } function union(ast: SchemaAST.Union): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 1d999e979d5..7ce43af5fba 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema, SchemaGetter } from "effect" +import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" import { zod, ZodOverride } from "../../src/util/effect-zod" @@ -669,4 +669,89 @@ describe("util.effect-zod", () => { expect(shape.properties.port.exclusiveMinimum).toBe(0) }) }) + + describe("Schema.optionalWith defaults", () => { + test("parsing undefined returns the default value", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + expect(schema.parse({})).toEqual({ mode: "ctrl-x" }) + expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" }) + }) + + test("parsing a real value returns that value (default does not fire)", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" }) + }) + + test("default on a number field", () => { + const schema = zod( + Schema.Struct({ + count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))), + }), + ) + expect(schema.parse({})).toEqual({ count: 42 }) + expect(schema.parse({ count: 7 })).toEqual({ count: 7 }) + }) + + test("multiple defaulted fields inside a struct", () => { + const schema = zod( + Schema.Struct({ + leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))), + inner: Schema.String, + }), + ) + expect(schema.parse({ inner: "hi" })).toEqual({ + leader: "ctrl-x", + quit: "ctrl-c", + inner: "hi", + }) + expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({ + leader: "a", + quit: "b", + inner: "c", + }) + }) + + test("JSON Schema output includes the default key", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + const shape = json(schema) as any + expect(shape.properties.mode.default).toBe("ctrl-x") + }) + + test("default referencing a computed value resolves when evaluated", () => { + // Simulates `keybinds.ts` style of per-platform defaults: the default is + // produced by an Effect that computes a value at decode time. + const platform = "darwin" + const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k" + const schema = zod( + Schema.Struct({ + command_palette: Schema.String.pipe( + Schema.optional, + Schema.withDecodingDefault(Effect.sync(() => fallback)), + ), + }), + ) + expect(schema.parse({})).toEqual({ command_palette: "cmd-k" }) + const shape = json(schema) as any + expect(shape.properties.command_palette.default).toBe("cmd-k") + }) + + test("plain Schema.optional (no default) still emits .optional() (regression)", () => { + const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) })) + expect(schema.parse({})).toEqual({}) + expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) + }) + }) }) From f52ae28432f66eef13ea48203899b23992f89eb9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 00:56:33 +0000 Subject: [PATCH 058/130] chore: generate --- packages/opencode/test/util/effect-zod.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 7ce43af5fba..70cd8f0e647 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -737,10 +737,7 @@ describe("util.effect-zod", () => { const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k" const schema = zod( Schema.Struct({ - command_palette: Schema.String.pipe( - Schema.optional, - Schema.withDecodingDefault(Effect.sync(() => fallback)), - ), + command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))), }), ) expect(schema.parse({})).toEqual({ command_palette: "cmd-k" }) From 5181f9b4e141fc6c0c3c3d26169721af9e63ca51 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:04:40 -0400 Subject: [PATCH 059/130] refactor(config): drop ZodOverride from PositiveInt in provider.ts (#23215) --- packages/opencode/src/config/provider.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index b435f43759a..4b08592a657 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,12 +1,10 @@ import { Schema } from "effect" -import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0). -const PositiveInt = Schema.Number.annotate({ - [ZodOverride]: z.number().int().positive(), -}) +// Positive integer: emits JSON Schema `type: integer, exclusiveMinimum: 0` +// via the effect-zod walker's well-known refinement translation. +const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) export const Model = Schema.Struct({ id: Schema.optional(Schema.String), From 23a2d0128254480e51c630f8cd077af29dc39efa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:14:23 -0400 Subject: [PATCH 060/130] fix(observability): standardize session telemetry attrs (#23213) --- packages/opencode/src/effect/logger.ts | 8 +++++++- packages/opencode/src/session/llm.ts | 16 ++++++++++++++-- packages/opencode/src/session/processor.ts | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/effect/logger.ts b/packages/opencode/src/effect/logger.ts index 21e0fc43ac1..0e58b8acb4b 100644 --- a/packages/opencode/src/effect/logger.ts +++ b/packages/opencode/src/effect/logger.ts @@ -3,6 +3,8 @@ import { Log } from "@/util" type Fields = Record +const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key) + export interface Handle { readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect @@ -12,7 +14,11 @@ export interface Handle { } const clean = (input?: Fields): Fields => - Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null)) + Object.fromEntries( + Object.entries(input ?? {}) + .filter((entry) => entry[1] !== undefined && entry[1] !== null) + .map(([key, value]) => [normalizeKey(key), value]), + ) const text = (input: unknown): string => { // oxlint-disable-next-line no-base-to-string diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b66e99fc82c..b72f873de01 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -74,7 +74,7 @@ const live: Layer.Layer< .clone() .tag("providerID", input.model.providerID) .tag("modelID", input.model.id) - .tag("sessionID", input.sessionID) + .tag("session.id", input.sessionID) .tag("small", (input.small ?? false).toString()) .tag("agent", input.agent.name) .tag("mode", input.agent.mode) @@ -317,6 +317,18 @@ const live: Layer.Layer< const tracer = cfg.experimental?.openTelemetry ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) : undefined + const telemetryTracer = tracer + ? new Proxy(tracer, { + get(target, prop, receiver) { + if (prop !== "startSpan") return Reflect.get(target, prop, receiver) + return (...args: Parameters) => { + const span = target.startSpan(...args) + span.setAttribute("session.id", input.sessionID) + return span + } + }, + }) + : undefined return streamText({ onError(error) { @@ -390,7 +402,7 @@ const live: Layer.Layer< experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, functionId: "session.llm", - tracer, + tracer: telemetryTracer, metadata: { userId: cfg.username ?? "unknown", sessionId: input.sessionID, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9ab74ca3411..21f9329c6fc 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -123,7 +123,7 @@ export const layer: Layer.Layer< reasoningMap: {}, } let aborted = false - const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id) + const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) const parse = (e: unknown) => MessageV2.fromError(e, { From 826fd3350ce8aa1a855956f956038fad7ecffb86 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:20:06 -0400 Subject: [PATCH 061/130] refactor(config): migrate Server + Layout to Effect Schema (#23216) --- packages/opencode/src/config/config.ts | 38 ++++++++---------------- packages/opencode/src/config/layout.ts | 10 +++++++ packages/opencode/src/config/provider.ts | 2 -- packages/opencode/src/config/server.ts | 20 +++++++++++++ 4 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 packages/opencode/src/config/layout.ts create mode 100644 packages/opencode/src/config/server.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bfb0c2f1f40..179c6a60939 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,18 +25,20 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { ConfigAgent } from "./agent" +import { ConfigCommand } from "./command" +import { ConfigFormatter } from "./formatter" +import { ConfigLayout } from "./layout" +import { ConfigLSP } from "./lsp" +import { ConfigManaged } from "./managed" import { ConfigMCP } from "./mcp" import { ConfigModelID } from "./model-id" -import { ConfigPlugin } from "./plugin" -import { ConfigManaged } from "./managed" -import { ConfigCommand } from "./command" import { ConfigParse } from "./parse" -import { ConfigPermission } from "./permission" -import { ConfigProvider } from "./provider" -import { ConfigSkills } from "./skills" import { ConfigPaths } from "./paths" -import { ConfigFormatter } from "./formatter" -import { ConfigLSP } from "./lsp" +import { ConfigPermission } from "./permission" +import { ConfigPlugin } from "./plugin" +import { ConfigProvider } from "./provider" +import { ConfigServer } from "./server" +import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" @@ -73,23 +75,9 @@ async function resolveLoadedPlugins( return config } -export const Server = z - .object({ - port: z.number().int().positive().optional().describe("Port to listen on"), - hostname: z.string().optional().describe("Hostname to listen on"), - mdns: z.boolean().optional().describe("Enable mDNS service discovery"), - mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), - cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), - }) - .strict() - .meta({ - ref: "ServerConfig", - }) - -export const Layout = z.enum(["auto", "stretch"]).meta({ - ref: "LayoutConfig", -}) -export type Layout = z.infer +export const Server = ConfigServer.Server.zod +export const Layout = ConfigLayout.Layout.zod +export type Layout = ConfigLayout.Layout export const Info = z .object({ diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts new file mode 100644 index 00000000000..49c34b66398 --- /dev/null +++ b/packages/opencode/src/config/layout.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" + +export const Layout = Schema.Literals(["auto", "stretch"]) + .annotate({ identifier: "LayoutConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Layout = Schema.Schema.Type + +export * as ConfigLayout from "./layout" diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 4b08592a657..212e716251f 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -2,8 +2,6 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -// Positive integer: emits JSON Schema `type: integer, exclusiveMinimum: 0` -// via the effect-zod walker's well-known refinement translation. const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) export const Model = Schema.Struct({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts new file mode 100644 index 00000000000..969a79964b3 --- /dev/null +++ b/packages/opencode/src/config/server.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" + +export class Server extends Schema.Class("ServerConfig")({ + port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({ + description: "Port to listen on", + }), + hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }), + mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }), + mdnsDomain: Schema.optional(Schema.String).annotate({ + description: "Custom domain name for mDNS service (default: opencode.local)", + }), + cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional domains to allow for CORS", + }), +}) { + static readonly zod = zod(this) +} + +export * as ConfigServer from "./server" From a92c75e5f4286d603c44887e50f7f9adf9e56b40 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 01:21:01 +0000 Subject: [PATCH 062/130] chore: generate --- packages/sdk/openapi.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5a93c4db2a8..b97d596b937 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10949,8 +10949,7 @@ "type": "string" } } - }, - "additionalProperties": false + } }, "PermissionActionConfig": { "type": "string", From a5d99e7a3c484fc020ae1f9a466542332b103807 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:22:36 -0400 Subject: [PATCH 063/130] refactor: pass formatter instance context explicitly (#23020) --- packages/opencode/src/format/formatter.ts | 40 ++++++++++++----------- packages/opencode/src/format/index.ts | 6 ++-- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 36249db7db9..03f8365274a 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,15 +1,17 @@ import { Npm } from "../npm" -import { Instance } from "../project/instance" +import type { InstanceContext } from "../project/instance" import { Filesystem } from "../util" import { Process } from "../util" import { which } from "../util/which" import { Flag } from "@/flag/flag" +export interface Context extends Pick {} + export interface Info { name: string environment?: Record extensions: string[] - enabled(): Promise + enabled(context: Context): Promise } export const gofmt: Info = { @@ -65,8 +67,8 @@ export const prettier: Info = { ".graphql", ".gql", ], - async enabled() { - const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree) + async enabled(context) { + const items = await Filesystem.findUp("package.json", context.directory, context.worktree) for (const item of items) { const json = await Filesystem.readJson<{ dependencies?: Record @@ -87,9 +89,9 @@ export const oxfmt: Info = { BUN_BE_BUN: "1", }, extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], - async enabled() { + async enabled(context) { if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false - const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree) + const items = await Filesystem.findUp("package.json", context.directory, context.worktree) for (const item of items) { const json = await Filesystem.readJson<{ dependencies?: Record @@ -137,10 +139,10 @@ export const biome: Info = { ".graphql", ".gql", ], - async enabled() { + async enabled(context) { const configs = ["biome.json", "biome.jsonc"] for (const config of configs) { - const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) + const found = await Filesystem.findUp(config, context.directory, context.worktree) if (found.length > 0) { const bin = await Npm.which("@biomejs/biome") if (bin) return [bin, "format", "--write", "$FILE"] @@ -163,8 +165,8 @@ export const zig: Info = { export const clang: Info = { name: "clang-format", extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], - async enabled() { - const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) + async enabled(context) { + const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree) if (items.length > 0) { const match = which("clang-format") if (match) return [match, "-i", "$FILE"] @@ -186,11 +188,11 @@ export const ktlint: Info = { export const ruff: Info = { name: "ruff", extensions: [".py", ".pyi"], - async enabled() { + async enabled(context) { if (!which("ruff")) return false const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] for (const config of configs) { - const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) + const found = await Filesystem.findUp(config, context.directory, context.worktree) if (found.length > 0) { if (config === "pyproject.toml") { const content = await Filesystem.readText(found[0]) @@ -202,7 +204,7 @@ export const ruff: Info = { } const deps = ["requirements.txt", "pyproject.toml", "Pipfile"] for (const dep of deps) { - const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) + const found = await Filesystem.findUp(dep, context.directory, context.worktree) if (found.length > 0) { const content = await Filesystem.readText(found[0]) if (content.includes("ruff")) return ["ruff", "format", "$FILE"] @@ -233,8 +235,8 @@ export const rlang: Info = { export const uvformat: Info = { name: "uv", extensions: [".py", ".pyi"], - async enabled() { - if (await ruff.enabled()) return false + async enabled(context) { + if (await ruff.enabled(context)) return false const uv = which("uv") if (uv == null) return false const output = await Process.run([uv, "format", "--help"], { nothrow: true }) @@ -286,9 +288,9 @@ export const dart: Info = { export const ocamlformat: Info = { name: "ocamlformat", extensions: [".ml", ".mli"], - async enabled() { + async enabled(context) { if (!which("ocamlformat")) return false - const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) + const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree) if (items.length > 0) return ["ocamlformat", "-i", "$FILE"] return false }, @@ -357,8 +359,8 @@ export const rustfmt: Info = { export const pint: Info = { name: "pint", extensions: [".php"], - async enabled() { - const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) + async enabled(context) { + const items = await Filesystem.findUp("composer.json", context.directory, context.worktree) for (const item of items) { const json = await Filesystem.readJson<{ require?: Record diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 2d0f80a10c7..85934ce9c9a 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -37,14 +37,14 @@ export const layer = Layer.effect( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const state = yield* InstanceState.make( - Effect.fn("Format.state")(function* (_ctx) { + Effect.fn("Format.state")(function* (ctx) { const commands: Record = {} const formatters: Record = {} async function getCommand(item: Formatter.Info) { let cmd = commands[item.name] if (cmd === false || cmd === undefined) { - cmd = await item.enabled() + cmd = await item.enabled(ctx) commands[item.name] = cmd } return cmd @@ -153,7 +153,7 @@ export const layer = Layer.effect( ...info, name, extensions: info.extensions ?? [], - enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false, + enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false, } } } From e6fd57165e7dad5796de278b4127614e3e08ba4f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:47:59 -0400 Subject: [PATCH 064/130] refactor: remove ambient instance reads from lsp (#23023) --- packages/opencode/src/lsp/client.ts | 39 ++++++------ packages/opencode/src/lsp/lsp.ts | 22 ++++--- packages/opencode/src/lsp/server.ts | 78 +++++++++++------------ packages/opencode/test/lsp/client.test.ts | 3 + 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 59a64ca1ed2..b20e8ae7f00 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,7 +11,6 @@ import z from "zod" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" -import { Instance } from "../project/instance" import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 @@ -39,7 +38,7 @@ export const Event = { ), } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { +export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle return connection }, notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) + async open(request: { path: string }) { + request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path) + const text = await Filesystem.readText(request.path) + const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[input.path] + const version = files[request.path] if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) + log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, type: 2, // Changed }, ], }) const next = version + 1 - files[input.path] = next + files[request.path] = next log.info("textDocument/didChange", { - path: input.path, + path: request.path, version: next, }) await connection.sendNotification("textDocument/didChange", { textDocument: { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, version: next, }, contentChanges: [{ text }], @@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle return } - log.info("workspace/didChangeWatchedFiles", input) + log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, type: 1, // Created }, ], }) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) + log.info("textDocument/didOpen", request) + diagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, languageId, version: 0, text, }, }) - files[input.path] = 0 + files[request.path] = 0 return }, }, get diagnostics() { return diagnostics }, - async waitForDiagnostics(input: { path: string }) { + async waitForDiagnostics(request: { path: string }) { const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) log.info("waiting for diagnostics", { path: normalizedPath }) let unsub: () => void diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 43c83098701..221385d2659 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" import z from "zod" import { Config } from "../config" -import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" const log = Log.create({ service: "lsp" }) @@ -162,7 +162,7 @@ export const layer = Layer.effect( const config = yield* Config.Service const state = yield* InstanceState.make( - Effect.fn("LSP.state")(function* () { + Effect.fn("LSP.state")(function* (ctx) { const cfg = yield* config.get() const servers: Record = {} @@ -187,7 +187,7 @@ export const layer = Layer.effect( servers[name] = { ...existing, id: name, - root: existing?.root ?? (async () => Instance.directory), + root: existing?.root ?? (async (_file, ctx) => ctx.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => ({ process: lspspawn(item.command[0], item.command.slice(1), { @@ -225,7 +225,10 @@ export const layer = Layer.effect( ) const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const ctx = yield* InstanceState.context + if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) { + return [] as LSPClient.Info[] + } const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file @@ -233,7 +236,7 @@ export const layer = Layer.effect( async function schedule(server: LSPServer.Info, root: string, key: string) { const handle = await server - .spawn(root) + .spawn(root, ctx) .then((value) => { if (!value) s.broken.add(key) return value @@ -251,6 +254,7 @@ export const layer = Layer.effect( serverID: server.id, server: handle, root, + directory: ctx.directory, }).catch(async (err) => { s.broken.add(key) await Process.stop(handle.process) @@ -273,7 +277,7 @@ export const layer = Layer.effect( for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) + const root = await server.root(file, ctx) if (!root) continue if (s.broken.has(root + server.id)) continue @@ -326,13 +330,14 @@ export const layer = Layer.effect( }) const status = Effect.fn("LSP.status")(function* () { + const ctx = yield* InstanceState.context const s = yield* InstanceState.get(state) const result: Status[] = [] for (const client of s.clients) { result.push({ id: client.serverID, name: s.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), + root: path.relative(ctx.directory, client.root), status: "connected", }) } @@ -340,12 +345,13 @@ export const layer = Layer.effect( }) const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const ctx = yield* InstanceState.context const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) + const root = await server.root(file, ctx) if (!root) continue if (s.broken.has(root + server.id)) continue return true diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 760e8eaba0e..e0221c9dd57 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -6,7 +6,7 @@ import { Log } from "../util" import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "../util" -import { Instance } from "../project/instance" +import type { InstanceContext } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util" import { Process } from "../util" @@ -29,15 +29,15 @@ export interface Handle { initialization?: Record } -type RootFunction = (file: string) => Promise +type RootFunction = (file: string, ctx: InstanceContext) => Promise const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { - return async (file) => { + return async (file, ctx) => { if (excludePatterns) { const excludedFiles = Filesystem.up({ targets: excludePatterns, start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const excluded = await excludedFiles.next() await excludedFiles.return() @@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo const files = Filesystem.up({ targets: includePatterns, start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const first = await files.next() await files.return() - if (!first.value) return Instance.directory + if (!first.value) return ctx.directory return path.dirname(first.value) } } @@ -60,16 +60,16 @@ export interface Info { extensions: string[] global?: boolean root: RootFunction - spawn(root: string): Promise + spawn(root: string, ctx: InstanceContext): Promise } export const Deno: Info = { id: "deno", - root: async (file) => { + root: async (file, ctx) => { const files = Filesystem.up({ targets: ["deno.json", "deno.jsonc"], start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const first = await files.next() await files.return() @@ -98,8 +98,8 @@ export const Typescript: Info = { ["deno.json", "deno.jsonc"], ), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + async spawn(root, ctx) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) log.info("typescript server", { tsserver }) if (!tsserver) return const bin = await Npm.which("typescript-language-server") @@ -154,8 +154,8 @@ export const ESLint: Info = { id: "eslint", root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root) { - const eslint = Module.resolve("eslint", Instance.directory) + async spawn(root, ctx) { + const eslint = Module.resolve("eslint", ctx.directory) if (!eslint) return log.info("spawning eslint server") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") @@ -219,7 +219,7 @@ export const Oxlint: Info = { "package.json", ]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - async spawn(root) { + async spawn(root, ctx) { const ext = process.platform === "win32" ? ".cmd" : "" const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) @@ -232,7 +232,7 @@ export const Oxlint: Info = { const candidates = Filesystem.up({ targets: [target], start: root, - stop: Instance.worktree, + stop: ctx.worktree, }) const first = await candidates.next() await candidates.return() @@ -344,10 +344,10 @@ export const Biome: Info = { export const Gopls: Info = { id: "gopls", - root: async (file) => { - const work = await NearestRoot(["go.work"])(file) + root: async (file, ctx) => { + const work = await NearestRoot(["go.work"])(file, ctx) if (work) return work - return NearestRoot(["go.mod", "go.sum"])(file) + return NearestRoot(["go.mod", "go.sum"])(file, ctx) }, extensions: [".go"], async spawn(root) { @@ -810,8 +810,8 @@ export const SourceKit: Info = { export const RustAnalyzer: Info = { id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) + root: async (file, ctx) => { + const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx) if (crateRoot === undefined) { return undefined } @@ -834,7 +834,7 @@ export const RustAnalyzer: Info = { currentDir = parentDir // Stop if we've gone above the app root - if (!currentDir.startsWith(Instance.worktree)) break + if (!currentDir.startsWith(ctx.worktree)) break } return crateRoot @@ -1031,8 +1031,8 @@ export const Astro: Info = { id: "astro", extensions: [".astro"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + async spawn(root, ctx) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) if (!tsserver) { log.info("typescript not found, required for Astro language server") return @@ -1067,7 +1067,7 @@ export const Astro: Info = { export const JDTLS: Info = { id: "jdtls", - root: async (file) => { + root: async (file, ctx) => { // Without exclusions, NearestRoot defaults to instance directory so we can't // distinguish between a) no project found and b) project found at instance dir. // So we can't choose the root from (potential) monorepo markers first. @@ -1080,9 +1080,9 @@ export const JDTLS: Info = { NearestRoot( ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos, - )(file), - NearestRoot(gradleMarkers, settingsMarkers)(file), - NearestRoot(settingsMarkers)(file), + )(file, ctx), + NearestRoot(gradleMarkers, settingsMarkers)(file, ctx), + NearestRoot(settingsMarkers)(file, ctx), ]) // If projectRoot is undefined we know we are in a monorepo or no project at all. @@ -1189,18 +1189,18 @@ export const JDTLS: Info = { export const KotlinLS: Info = { id: "kotlin-ls", extensions: [".kt", ".kts"], - root: async (file) => { + root: async (file, ctx) => { // 1) Nearest Gradle root (multi-project or included build) - const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) + const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx) if (settingsRoot) return settingsRoot // 2) Gradle wrapper (strong root signal) - const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) + const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx) if (wrapperRoot) return wrapperRoot // 3) Single-project or module-level build - const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) + const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx) if (buildRoot) return buildRoot // 4) Maven fallback - return NearestRoot(["pom.xml"])(file) + return NearestRoot(["pom.xml"])(file, ctx) }, async spawn(root) { const distPath = path.join(Global.Path.bin, "kotlin-ls") @@ -1539,7 +1539,7 @@ export const Ocaml: Info = { export const BashLS: Info = { id: "bash", extensions: [".sh", ".bash", ".zsh", ".ksh"], - root: async () => Instance.directory, + root: async (_file, ctx) => ctx.directory, async spawn(root) { let binary = which("bash-language-server") const args: string[] = [] @@ -1734,7 +1734,7 @@ export const TexLab: Info = { export const DockerfileLS: Info = { id: "dockerfile", extensions: [".dockerfile", "Dockerfile"], - root: async () => Instance.directory, + root: async (_file, ctx) => ctx.directory, async spawn(root) { let binary = which("docker-langserver") const args: string[] = [] @@ -1799,16 +1799,16 @@ export const Clojure: Info = { export const Nixd: Info = { id: "nixd", extensions: [".nix"], - root: async (file) => { + root: async (file, ctx) => { // First, look for flake.nix - the most reliable Nix project root indicator - const flakeRoot = await NearestRoot(["flake.nix"])(file) - if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot + const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx) + if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot // If no flake.nix, fall back to git repository root - if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree + if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree // Finally, use the instance directory as fallback - return Instance.directory + return ctx.directory }, async spawn(root) { const nixd = which("nixd") diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f124fddf958..d6eaa317f94 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -31,6 +31,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), }) @@ -55,6 +56,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), }) @@ -79,6 +81,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), }) From 866188a643271ddfca3c3ae007d6cddad8d0d11c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 01:48:50 +0000 Subject: [PATCH 065/130] chore: generate --- packages/opencode/src/lsp/lsp.ts | 5 ++++- packages/opencode/src/lsp/server.ts | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 221385d2659..aa519f9f7e7 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -226,7 +226,10 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) { + if ( + !AppFileSystem.contains(ctx.directory, file) && + (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) + ) { return [] as LSPClient.Info[] } const s = yield* InstanceState.get(state) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e0221c9dd57..91823680635 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1077,10 +1077,10 @@ export const JDTLS: Info = { const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ - NearestRoot( - ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], - exclusionsForMonorepos, - )(file, ctx), + NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos)( + file, + ctx, + ), NearestRoot(gradleMarkers, settingsMarkers)(file, ctx), NearestRoot(settingsMarkers)(file, ctx), ]) From 81b7b58a5e396fb7d932ac77c7503d6271fa30ea Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:57:48 -0500 Subject: [PATCH 066/130] fix: gh copilot issue w/ haiku (eager_input_streaming not supported) (#23223) --- packages/opencode/src/plugin/github-copilot/copilot.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c018f72bd57..9b6f54459dd 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -334,6 +334,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { if (incoming.model.api.id.includes("gpt")) { output.maxOutputTokens = undefined } + + // GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming` + // field on tool definitions ("Extra inputs are not permitted"). Opt out of + // the @ai-sdk/anthropic default so it stops injecting the field. + if (incoming.model.api.npm === "@ai-sdk/anthropic") { + output.options.toolStreaming = false + } }, "chat.headers": async (incoming, output) => { if (!incoming.model.providerID.includes("github-copilot")) return From 1fae784b81a01fed3dcd6209e26427410158af30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:02:37 -0400 Subject: [PATCH 067/130] feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms (#23222) --- packages/opencode/src/util/effect-zod.ts | 41 +++++- .../opencode/test/util/effect-zod.test.ts | 118 +++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index cdcd99c9761..bf1caa035b0 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -8,6 +8,43 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") +/** + * Annotation key for a pre-parse transform that runs on the raw input before + * the derived Zod schema validates it. The walker emits + * `z.preprocess(fn, inner)` when this annotation is present. + * + * Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema + * needs to inspect the user's raw input (e.g. to capture insertion order) + * before `Schema.Struct` canonicalises the object. + * + * TODO: This exists to paper over a missing Effect Schema feature. The + * parser canonicalises open struct output (known fields first in + * declaration order, then catchall fields) before any user-defined + * transform sees the value, and there is no pre-parse hook — so the + * user's original property insertion order is gone by the time + * `Schema.decodeTo` or `middlewareDecoding` runs. + * + * That canonicalisation is a reasonable default, but `config/permission.ts` + * encodes rule precedence in the user's JSON key order (`evaluate.ts` + * uses `findLast`, so later entries win), which the canonicalisation + * silently destroys. + * + * The cleanest upstream fix would be either: + * + * 1. A `preserveInputOrder` option on `Schema.Struct` / + * `Schema.StructWithRest` that keeps the input's insertion order in + * the parsed object (opt-in; canonical order stays default). + * 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a + * transformation whose decode receives the raw `unknown`). + * + * Either of those would let us delete `ZodPreprocess` and the + * `__originalKeys` hack. Alternatively, the permission model could move + * to specificity-based precedence (exact keys beat wildcards) or an + * explicit ordered array of rules, which removes the ordering + * dependency at the data-model level. + */ +export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess") + // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable @@ -47,7 +84,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) - const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base + const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base + const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess] + const out = preprocess ? z.preprocess(preprocess, checked) : checked const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) const described = desc ? out.describe(desc) : out diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e647..9bf3de3f84c 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) @@ -751,4 +751,120 @@ describe("util.effect-zod", () => { expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) }) }) + + describe("ZodPreprocess annotation", () => { + test("preprocess runs on raw input before the inner schema parses", () => { + // Models the permission.ts __originalKeys pattern: capture the original + // insertion order of a user-provided object BEFORE Schema parsing + // canonicalises the keys. + const preprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + return { __keys: Object.keys(val), ...(val as Record) } + } + return val + } + const Inner = Schema.Struct({ + __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + a: Schema.optional(Schema.String), + b: Schema.optional(Schema.String), + }).annotate({ [ZodPreprocess]: preprocess }) + + const schema = zod(Inner) + const parsed = schema.parse({ b: "1", a: "2" }) as { + __keys?: string[] + a?: string + b?: string + } + expect(parsed.__keys).toEqual(["b", "a"]) + expect(parsed.a).toBe("2") + expect(parsed.b).toBe("1") + }) + + test("preprocess does not transform already-shaped input", () => { + // When the user passes an object that already has __keys, preprocess + // returns it unchanged because spreading preserves any existing key. + const preprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !("__keys" in val)) { + return { __keys: Object.keys(val), ...(val as Record) } + } + return val + } + const Inner = Schema.Struct({ + __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + a: Schema.optional(Schema.String), + }).annotate({ [ZodPreprocess]: preprocess }) + + const schema = zod(Inner) + const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as { + __keys?: string[] + a?: string + } + expect(parsed.__keys).toEqual(["existing"]) + }) + + test("preprocess composes with a union (either object or string)", () => { + // Mirrors permission.ts exactly: input can be either an object (with + // preprocess injecting metadata) or a plain string action. + const Action = Schema.Literals(["ask", "allow", "deny"]) + const Obj = Schema.Struct({ + __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + read: Schema.optional(Action), + write: Schema.optional(Action), + }) + const preprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + return { __keys: Object.keys(val), ...(val as Record) } + } + return val + } + const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess }) + const schema = zod(Inner) + + // String branch — passes through preprocess unchanged + expect(schema.parse("allow")).toBe("allow") + + // Object branch — __keys injected, preserves order + const parsed = schema.parse({ write: "allow", read: "deny" }) as { + __keys?: string[] + read?: string + write?: string + } + expect(parsed.__keys).toEqual(["write", "read"]) + expect(parsed.write).toBe("allow") + expect(parsed.read).toBe("deny") + }) + + test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => { + const Inner = Schema.Struct({ + a: Schema.optional(Schema.String), + b: Schema.optional(Schema.Number), + }).annotate({ [ZodPreprocess]: (v: unknown) => v }) + const shape = json(zod(Inner)) as any + expect(shape.type).toBe("object") + expect(shape.properties.a.type).toBe("string") + expect(shape.properties.b.type).toBe("number") + }) + + test("identifier + description propagate through the preprocess wrapper", () => { + const Inner = Schema.Struct({ + x: Schema.optional(Schema.String), + }) + .annotate({ + identifier: "WithPreproc", + description: "A schema with preprocess", + [ZodPreprocess]: (v: unknown) => v, + }) + const schema = zod(Inner) + expect(schema.meta()?.ref).toBe("WithPreproc") + expect(schema.meta()?.description).toBe("A schema with preprocess") + }) + + test("preprocess inside a struct field applies only to that field", () => { + const Inner = Schema.String.annotate({ + [ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v), + }) + const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number })) + expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 }) + }) + }) }) From daaa1c7e26a06d2239133c857d7f8863b60fb97a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 02:03:30 +0000 Subject: [PATCH 068/130] chore: generate --- packages/opencode/test/util/effect-zod.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 9bf3de3f84c..003945b4344 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -848,12 +848,11 @@ describe("util.effect-zod", () => { test("identifier + description propagate through the preprocess wrapper", () => { const Inner = Schema.Struct({ x: Schema.optional(Schema.String), + }).annotate({ + identifier: "WithPreproc", + description: "A schema with preprocess", + [ZodPreprocess]: (v: unknown) => v, }) - .annotate({ - identifier: "WithPreproc", - description: "A schema with preprocess", - [ZodPreprocess]: (v: unknown) => v, - }) const schema = zod(Inner) expect(schema.meta()?.ref).toBe("WithPreproc") expect(schema.meta()?.description).toBe("A schema with preprocess") From 5fa1673341f2ebde860cb06799626c6dfbcde2c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:08:57 -0400 Subject: [PATCH 069/130] refactor: use InstanceState context in File service (#23015) --- packages/opencode/src/file/index.ts | 48 +++++++++++++---------- packages/opencode/src/project/instance.ts | 2 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 2f30b5400d6..af4fbf76c80 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -356,8 +356,9 @@ export const layer = Layer.effect( ) const scan = Effect.fn("File.scan")(function* () { - if (Instance.directory === path.parse(Instance.directory).root) return - const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const ctx = yield* InstanceState.context + if (ctx.directory === path.parse(ctx.directory).root) return + const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global" const next: Entry = { files: [], dirs: [] } if (isGlobalHome) { @@ -366,14 +367,14 @@ export const layer = Layer.effect( const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => [])) + const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => [])) for (const entry of top) { if (entry.type !== "directory") continue if (shouldIgnoreName(entry.name)) continue dirs.add(entry.name + "/") - const base = path.join(Instance.directory, entry.name) + const base = path.join(ctx.directory, entry.name) const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => [])) for (const child of children) { if (child.type !== "directory") continue @@ -384,7 +385,7 @@ export const layer = Layer.effect( next.dirs = Array.from(dirs).toSorted() } else { - const files = yield* rg.files({ cwd: Instance.directory }).pipe( + const files = yield* rg.files({ cwd: ctx.directory }).pipe( Stream.runCollect, Effect.map((chunk) => [...chunk]), ) @@ -416,7 +417,7 @@ export const layer = Layer.effect( }) const gitText = Effect.fnUntraced(function* (args: string[]) { - return (yield* git.run(args, { cwd: Instance.directory })).text() + return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text() }) const init = Effect.fn("File.init")(function* () { @@ -424,7 +425,8 @@ export const layer = Layer.effect( }) const status = Effect.fn("File.status")(function* () { - if (Instance.project.vcs !== "git") return [] + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] const diffOutput = yield* gitText([ "-c", @@ -463,7 +465,7 @@ export const layer = Layer.effect( if (untrackedOutput.trim()) { for (const file of untrackedOutput.trim().split("\n")) { const content = yield* appFs - .readFileString(path.join(Instance.directory, file)) + .readFileString(path.join(ctx.directory, file)) .pipe(Effect.catch(() => Effect.succeed(undefined))) if (content === undefined) continue changed.push({ @@ -498,19 +500,22 @@ export const layer = Layer.effect( } return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path) return { ...item, - path: path.relative(Instance.directory, full), + path: path.relative(ctx.directory, full), } }) }) const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { using _ = log.time("read", { file }) - const full = path.join(Instance.directory, file) + const ctx = yield* InstanceState.context + const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory") + if (!Instance.containsPath(full, ctx)) { + throw new Error("Access denied: path escapes project directory") + } if (isImageByExtension(file)) { const exists = yield* appFs.existsSafe(full) @@ -553,13 +558,13 @@ export const layer = Layer.effect( Effect.catch(() => Effect.succeed("")), ) - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) if (!diff.trim()) { diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) } if (diff.trim()) { - const original = yield* git.show(Instance.directory, "HEAD", file) + const original = yield* git.show(ctx.directory, "HEAD", file) const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, @@ -573,21 +578,24 @@ export const layer = Layer.effect( }) const list = Effect.fn("File.list")(function* (dir?: string) { + const ctx = yield* InstanceState.context const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { const ig = ignore() - const gitignore = path.join(Instance.project.worktree, ".gitignore") + const gitignore = path.join(ctx.worktree, ".gitignore") const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed(""))) if (gitignoreText) ig.add(gitignoreText) - const ignoreFile = path.join(Instance.project.worktree, ".ignore") + const ignoreFile = path.join(ctx.worktree, ".ignore") const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed(""))) if (ignoreText) ig.add(ignoreText) ignored = ig.ignores.bind(ig) } - const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory") + const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory + if (!Instance.containsPath(resolved, ctx)) { + throw new Error("Access denied: path escapes project directory") + } const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => [])) @@ -595,7 +603,7 @@ export const layer = Layer.effect( for (const entry of entries) { if (exclude.includes(entry.name)) continue const absolute = path.join(resolved, entry.name) - const file = path.relative(Instance.directory, absolute) + const file = path.relative(ctx.directory, absolute) const type = entry.type === "directory" ? "directory" : "file" nodes.push({ name: entry.name, diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 056eede01b6..1c510962046 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -96,7 +96,7 @@ export const Instance = { 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 + if (instance.worktree === "/") return false return AppFileSystem.contains(instance.worktree, filepath) }, /** From 1dd257b76a98a159f763ec009bf061e6bd4ae718 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:16:15 -0400 Subject: [PATCH 070/130] refactor: use instance state in small services (#23022) --- packages/opencode/src/mcp/index.ts | 3 +-- packages/opencode/src/project/vcs.ts | 15 +++++---------- packages/opencode/src/provider/provider.ts | 7 ++----- packages/opencode/src/session/instruction.ts | 9 +++++---- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ba53e7c0b5d..09fcfc756a1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -14,7 +14,6 @@ import { ConfigMCP } from "../config/mcp" import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod/v4" -import { Instance } from "../project/instance" import { Installation } from "../installation" import { InstallationVersion } from "../installation/version" import { withTimeout } from "@/util/timeout" @@ -391,7 +390,7 @@ export const layer = Layer.effect( mcp: ConfigMCP.Info & { type: "local" }, ) { const [cmd, ...args] = mcp.command - const cwd = Instance.directory + const cwd = yield* InstanceState.directory const transport = new StdioClientTransport({ stderr: "pipe", command: cmd, diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index b1375a7b78e..ba028f7e8e3 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -8,7 +8,6 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import { Log } from "@/util" -import { Instance } from "./instance" import z from "zod" const log = Log.create({ service: "vcs" }) @@ -205,21 +204,17 @@ export const layer: Layer.Layer { const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN")) const providerConfig = (yield* dep.config()).provider?.["gitlab"] + const directory = yield* InstanceState.directory const aiGatewayHeaders = { "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, @@ -591,10 +591,7 @@ function custom(dep: CustomDep): Record { auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } log.info("gitlab model discovery starting", { instanceUrl }) - const result = await discoverWorkflowModels( - { instanceUrl, getHeaders }, - { workingDirectory: Instance.directory }, - ) + const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory }) if (!result.models.length) { log.info("gitlab model discovery skipped: no models found", { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 768f352d93d..122644c1fd8 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -8,7 +8,6 @@ import { Flag } from "@/flag/flag" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" -import { Instance } from "../project/instance" import { Log } from "../util" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" @@ -82,9 +81,10 @@ export const layer: Layer.Layer Effect.succeed([] as string[]))) } if (!Flag.OPENCODE_CONFIG_DIR) { @@ -119,12 +119,13 @@ export const layer: Layer.Layer() // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of FILES) { - const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree) + const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) if (matches.length > 0) { matches.forEach((item) => paths.add(path.resolve(item))) break @@ -191,9 +192,9 @@ export const layer: Layer.Layer Date: Fri, 17 Apr 2026 22:28:45 -0400 Subject: [PATCH 071/130] refactor(config): migrate keybinds.ts to Effect Schema (#23227) --- packages/opencode/src/config/keybinds.ts | 287 ++++++++++------------- 1 file changed, 124 insertions(+), 163 deletions(-) diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index 8a22289d2a7..a84fc0b37d5 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -1,166 +1,127 @@ export * as ConfigKeybinds from "./keybinds" -import z from "zod" +import { Effect, Schema } from "effect" +import type z from "zod" +import { zod } from "@/util/effect-zod" -export const Keybinds = z - .object({ - leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), - app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), - editor_open: z.string().optional().default("e").describe("Open external editor"), - theme_list: z.string().optional().default("t").describe("List available themes"), - sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), - scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), - username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), - status_view: z.string().optional().default("s").describe("View status"), - session_export: z.string().optional().default("x").describe("Export session to editor"), - session_new: z.string().optional().default("n").describe("Create a new session"), - session_list: z.string().optional().default("l").describe("List all sessions"), - session_timeline: z.string().optional().default("g").describe("Show session timeline"), - session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), - session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), - stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), - model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), - session_share: z.string().optional().default("none").describe("Share current session"), - session_unshare: z.string().optional().default("none").describe("Unshare current session"), - session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), - session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown,ctrl+alt+f") - .describe("Scroll messages down by one page"), - messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), - messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), - messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), - messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"), - messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), - messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), - messages_next: z.string().optional().default("none").describe("Navigate to next message"), - messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), - messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), - messages_copy: z.string().optional().default("y").describe("Copy message"), - messages_undo: z.string().optional().default("u").describe("Undo message"), - messages_redo: z.string().optional().default("r").describe("Redo message"), - messages_toggle_conceal: z - .string() - .optional() - .default("h") - .describe("Toggle code block concealment in messages"), - tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), - model_list: z.string().optional().default("m").describe("List available models"), - model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), - model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), - model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), - model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), - command_list: z.string().optional().default("ctrl+p").describe("List available commands"), - agent_list: z.string().optional().default("a").describe("List agents"), - agent_cycle: z.string().optional().default("tab").describe("Next agent"), - agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), - variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), - variant_list: z.string().optional().default("none").describe("List model variants"), - input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), - input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), - input_submit: z.string().optional().default("return").describe("Submit input"), - input_newline: z - .string() - .optional() - .default("shift+return,ctrl+return,alt+return,ctrl+j") - .describe("Insert newline in input"), - input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), - input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), - input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), - input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), - input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), - input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), - input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), - input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), - input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), - input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), - input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"), - input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), - input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), - input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), - input_select_visual_line_home: z - .string() - .optional() - .default("alt+shift+a") - .describe("Select to start of visual line in input"), - input_select_visual_line_end: z - .string() - .optional() - .default("alt+shift+e") - .describe("Select to end of visual line in input"), - input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), - input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), - input_select_buffer_home: z - .string() - .optional() - .default("shift+home") - .describe("Select to start of buffer in input"), - input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), - input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), - input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), - input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), - input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), - input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z - .string() - .optional() - // On Windows prepend ctrl+z since terminal_suspend releases the binding. - .default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z") - .describe("Undo in input"), - input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), - input_word_forward: z - .string() - .optional() - .default("alt+f,alt+right,ctrl+right") - .describe("Move word forward in input"), - input_word_backward: z - .string() - .optional() - .default("alt+b,alt+left,ctrl+left") - .describe("Move word backward in input"), - input_select_word_forward: z - .string() - .optional() - .default("alt+shift+f,alt+shift+right") - .describe("Select word forward in input"), - input_select_word_backward: z - .string() - .optional() - .default("alt+shift+b,alt+shift+left") - .describe("Select word backward in input"), - input_delete_word_forward: z - .string() - .optional() - .default("alt+d,alt+delete,ctrl+delete") - .describe("Delete word forward in input"), - input_delete_word_backward: z - .string() - .optional() - .default("ctrl+w,ctrl+backspace,alt+backspace") - .describe("Delete word backward in input"), - history_previous: z.string().optional().default("up").describe("Previous history item"), - history_next: z.string().optional().default("down").describe("Next history item"), - session_child_first: z.string().optional().default("down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent session"), - terminal_suspend: z - .string() - .optional() - .default("ctrl+z") - .transform((v) => (process.platform === "win32" ? "none" : v)) - .describe("Suspend terminal"), - terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), - tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), - plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), - display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), - }) - .strict() - .meta({ - ref: "KeybindsConfig", - }) +// Every keybind field has the same shape: an optional string with a default +// binding and a human description. `keybind()` keeps the declaration list +// below dense and readable. +const keybind = (value: string, description: string) => + Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description }) + +// Windows prepends ctrl+z to the undo binding because `terminal_suspend` +// cannot consume ctrl+z on native Windows terminals (no POSIX suspend). +const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" + +const KeybindsSchema = Schema.Struct({ + leader: keybind("ctrl+x", "Leader key for keybind combinations"), + app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), + editor_open: keybind("e", "Open external editor"), + theme_list: keybind("t", "List available themes"), + sidebar_toggle: keybind("b", "Toggle sidebar"), + scrollbar_toggle: keybind("none", "Toggle session scrollbar"), + username_toggle: keybind("none", "Toggle username visibility"), + status_view: keybind("s", "View status"), + session_export: keybind("x", "Export session to editor"), + session_new: keybind("n", "Create a new session"), + session_list: keybind("l", "List all sessions"), + session_timeline: keybind("g", "Show session timeline"), + session_fork: keybind("none", "Fork session from message"), + session_rename: keybind("ctrl+r", "Rename session"), + session_delete: keybind("ctrl+d", "Delete session"), + stash_delete: keybind("ctrl+d", "Delete stash entry"), + model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), + model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), + session_share: keybind("none", "Share current session"), + session_unshare: keybind("none", "Unshare current session"), + session_interrupt: keybind("escape", "Interrupt current session"), + session_compact: keybind("c", "Compact the session"), + messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), + messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), + messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), + messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), + messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), + messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), + messages_first: keybind("ctrl+g,home", "Navigate to first message"), + messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), + messages_next: keybind("none", "Navigate to next message"), + messages_previous: keybind("none", "Navigate to previous message"), + messages_last_user: keybind("none", "Navigate to last user message"), + messages_copy: keybind("y", "Copy message"), + messages_undo: keybind("u", "Undo message"), + messages_redo: keybind("r", "Redo message"), + messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), + tool_details: keybind("none", "Toggle tool details visibility"), + model_list: keybind("m", "List available models"), + model_cycle_recent: keybind("f2", "Next recently used model"), + model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), + model_cycle_favorite: keybind("none", "Next favorite model"), + model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), + command_list: keybind("ctrl+p", "List available commands"), + agent_list: keybind("a", "List agents"), + agent_cycle: keybind("tab", "Next agent"), + agent_cycle_reverse: keybind("shift+tab", "Previous agent"), + variant_cycle: keybind("ctrl+t", "Cycle model variants"), + variant_list: keybind("none", "List model variants"), + input_clear: keybind("ctrl+c", "Clear input field"), + input_paste: keybind("ctrl+v", "Paste from clipboard"), + input_submit: keybind("return", "Submit input"), + input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), + input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), + input_move_up: keybind("up", "Move cursor up in input"), + input_move_down: keybind("down", "Move cursor down in input"), + input_select_left: keybind("shift+left", "Select left in input"), + input_select_right: keybind("shift+right", "Select right in input"), + input_select_up: keybind("shift+up", "Select up in input"), + input_select_down: keybind("shift+down", "Select down in input"), + input_line_home: keybind("ctrl+a", "Move to start of line in input"), + input_line_end: keybind("ctrl+e", "Move to end of line in input"), + input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), + input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), + input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), + input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), + input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), + input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), + input_buffer_home: keybind("home", "Move to start of buffer in input"), + input_buffer_end: keybind("end", "Move to end of buffer in input"), + input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), + input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), + input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), + input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), + input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), + input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), + input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), + input_undo: keybind(inputUndoDefault, "Undo in input"), + input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), + input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), + input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), + input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), + input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), + input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), + input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + history_previous: keybind("up", "Previous history item"), + history_next: keybind("down", "Next history item"), + session_child_first: keybind("down", "Go to first child session"), + session_child_cycle: keybind("right", "Go to next child session"), + session_child_cycle_reverse: keybind("left", "Go to previous child session"), + session_parent: keybind("up", "Go to parent session"), + // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`, + // but `tui.ts` already forces the binding to "none" on win32 before calling + // `Keybinds.parse(...)`, so the schema-level transform was redundant. + terminal_suspend: keybind("ctrl+z", "Suspend terminal"), + terminal_title_toggle: keybind("none", "Toggle terminal title"), + tips_toggle: keybind("h", "Toggle tips on home screen"), + plugin_manager: keybind("none", "Open plugin manager dialog"), + display_thinking: keybind("none", "Toggle thinking blocks visibility"), +}).annotate({ identifier: "KeybindsConfig" }) + +export type Keybinds = Schema.Schema.Type + +// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`, +// which requires the runtime type to be a ZodObject, not just ZodType. Every +// field is `string().optional().default(...)` at runtime, so widen to that. +export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject< + Record>> +> From 357301991624ae6c361350c1c1aca222268a6d5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:31:21 -0400 Subject: [PATCH 072/130] fix(generate): make openapi output deterministic by formatting in-place (#23228) --- packages/opencode/src/cli/cmd/generate.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index fad4514c81e..0531d537c20 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -25,7 +25,19 @@ export const GenerateCommand = { ] } } - const json = JSON.stringify(specs, null, 2) + const raw = JSON.stringify(specs, null, 2) + + // Format through prettier so output is byte-identical to committed file + // regardless of whether ./script/format.ts runs afterward. + const prettier = await import("prettier") + const babel = await import("prettier/plugins/babel") + const estree = await import("prettier/plugins/estree") + const format = prettier.format ?? prettier.default?.format + const json = await format(raw, { + parser: "json", + plugins: [babel.default ?? babel, estree.default ?? estree], + printWidth: 120, + }) // Wait for stdout to finish writing before process.exit() is called await new Promise((resolve, reject) => { From 24fb9b1296d7bb5942ef5690ee3a806856b18dae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 22:53:19 -0400 Subject: [PATCH 073/130] fix: stop rewriting dev during release publish (#22982) --- packages/opencode/script/publish.ts | 29 +++++++--- packages/plugin/script/publish.ts | 12 +++- packages/sdk/js/script/publish.ts | 37 ++++++++----- script/publish.ts | 86 ++++++++++++++++++++--------- script/version.ts | 6 +- 5 files changed, 120 insertions(+), 50 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 9c4b8f187d9..b444106a1b4 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -7,6 +7,20 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + if (await published(name, version)) { + console.log(`already published ${name}@${version}`) + return + } + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) + await $`bun pm pack`.cwd(dir) + await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) +} + const binaries: Record = {} for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { const pkg = await Bun.file(`./dist/${filepath}`).json() @@ -40,14 +54,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write( ) const tasks = Object.entries(binaries).map(async ([name]) => { - if (process.platform !== "win32") { - await $`chmod -R 755 .`.cwd(`./dist/${name}`) - } - await $`bun pm pack`.cwd(`./dist/${name}`) - await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`) + await publish(`./dist/${name}`, name, binaries[name]) }) await Promise.all(tasks) -await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}` +await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) const image = "ghcr.io/anomalyco/opencode" const platforms = "linux/amd64,linux/arm64" @@ -104,6 +114,7 @@ if (!Script.preview) { await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild) await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO` await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO` + if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` await $`cd ./dist/aur-${pkg} && git push` break @@ -176,6 +187,8 @@ if (!Script.preview) { await $`git clone ${tap} ./dist/homebrew-tap` await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) await $`cd ./dist/homebrew-tap && git add opencode.rb` - await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` - await $`cd ./dist/homebrew-tap && git push` + if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) { + await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` + await $`cd ./dist/homebrew-tap && git push` + } } diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index d2fe49f23ce..de129918cd5 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -6,9 +6,19 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + await $`bun tsc` -const pkg = await import("../package.json").then((m) => m.default) +const pkg = await import("../package.json").then( + (m) => m.default as { name: string; version: string; exports: Record }, +) const original = JSON.parse(JSON.stringify(pkg)) +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} for (const [key, value] of Object.entries(pkg.exports)) { const file = value.replace("./src/", "./dist/").replace(".ts", "") // @ts-ignore diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index ea5c5d634b2..b5e1211fc43 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -7,24 +7,35 @@ import { fileURLToPath } from "url" const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + const pkg = (await import("../package.json").then((m) => m.default)) as { - exports: Record + name: string + version: string + exports: Record } const original = JSON.parse(JSON.stringify(pkg)) -function transformExports(exports: Record) { - for (const [key, value] of Object.entries(exports)) { - if (typeof value === "object" && value !== null) { - transformExports(value as Record) - } else if (typeof value === "string") { - const file = value.replace("./src/", "./dist/").replace(".ts", "") - exports[key] = { - import: file + ".js", - types: file + ".d.ts", +function transformExports(exports: Record) { + return Object.fromEntries( + Object.entries(exports).map(([key, value]) => { + if (typeof value === "string") { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + return [key, { import: file + ".js", types: file + ".d.ts" }] } - } - } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return [key, transformExports(value)] + } + return [key, value] + }), + ) } -transformExports(pkg.exports) +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} +pkg.exports = transformExports(pkg.exports) await Bun.write("package.json", JSON.stringify(pkg, null, 2)) await $`bun pm pack` await $`npm publish *.tgz --tag ${Script.channel} --access public` diff --git a/script/publish.ts b/script/publish.ts index ac0af9c4e61..6cd244e0e6a 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -6,43 +6,58 @@ import { fileURLToPath } from "url" console.log("=== publishing ===\n") +const tag = `v${Script.version}` + const pkgjsons = await Array.fromAsync( new Bun.Glob("**/package.json").scan({ absolute: true, }), ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) -for (const file of pkgjsons) { - let pkg = await Bun.file(file).text() - pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) - console.log("updated:", file) - await Bun.file(file).write(pkg) +const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) + +async function hasChanges() { + return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 } -const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) -let toml = await Bun.file(extensionToml).text() -toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) -toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) -console.log("updated:", extensionToml) -await Bun.file(extensionToml).write(toml) +async function releaseTagExists() { + return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0 +} -await $`bun install` -await import(`../packages/sdk/js/script/build.ts`) - -if (Script.release) { - if (!Script.preview) { - await $`git commit -am "release: v${Script.version}"` - await $`git tag v${Script.version}` - await $`git fetch origin` - await $`git cherry-pick HEAD..origin/dev`.nothrow() - await $`git push origin HEAD --tags --no-verify --force-with-lease` - await new Promise((resolve) => setTimeout(resolve, 5_000)) +async function prepareReleaseFiles() { + for (const file of pkgjsons) { + let pkg = await Bun.file(file).text() + pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) + console.log("updated:", file) + await Bun.file(file).write(pkg) } - await import(`../packages/desktop/scripts/finalize-latest-json.ts`) - await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) + let toml = await Bun.file(extensionToml).text() + toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) + toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) + console.log("updated:", extensionToml) + await Bun.file(extensionToml).write(toml) - await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}` + await $`bun install` + await $`./packages/sdk/js/script/build.ts` +} + +if (Script.release && !Script.preview) { + await $`git fetch origin --tags` + await $`git switch --detach` +} + +await prepareReleaseFiles() + +if (Script.release && !Script.preview) { + if (await releaseTagExists()) { + console.log(`release tag ${tag} already exists, skipping tag creation`) + } else { + await $`git commit -am "release: ${tag}"` + await $`git tag ${tag}` + await $`git push origin refs/tags/${tag} --no-verify` + await new Promise((resolve) => setTimeout(resolve, 5_000)) + } } console.log("\n=== cli ===\n") @@ -54,5 +69,26 @@ await import(`../packages/sdk/js/script/publish.ts`) console.log("\n=== plugin ===\n") await import(`../packages/plugin/script/publish.ts`) +if (Script.release) { + await import(`../packages/desktop/scripts/finalize-latest-json.ts`) + await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) +} + +if (Script.release && !Script.preview) { + await $`git fetch origin` + await $`git checkout -B dev origin/dev` + await prepareReleaseFiles() + if (await hasChanges()) { + await $`git commit -am "sync release versions for v${Script.version}"` + await $`git push origin HEAD:dev --no-verify` + } else { + console.log(`dev already synced for ${tag}`) + } +} + +if (Script.release) { + await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}` +} + const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) diff --git a/script/version.ts b/script/version.ts index 3ca4f661d24..c1ad021b698 100755 --- a/script/version.ts +++ b/script/version.ts @@ -4,9 +4,9 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" const output = [`version=${Script.version}`] +const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() if (!Script.preview) { - const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()) const file = `${process.cwd()}/UPCOMING_CHANGELOG.md` const body = await Bun.file(file) @@ -15,12 +15,12 @@ if (!Script.preview) { const dir = process.env.RUNNER_TEMP ?? "/tmp" const notesFile = `${dir}/opencode-release-notes.txt` await Bun.write(notesFile, body) - await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}` + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}` const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } else if (Script.channel === "beta") { - await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}` + await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}` const release = await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() output.push(`release=${release.databaseId}`) From 471b9f4dc443869795e0732c668e366150951d36 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:04:16 -0400 Subject: [PATCH 074/130] refactor: use InstanceState context in worktree cleanup paths (#23019) --- packages/opencode/src/worktree/index.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index aa1dc2f8f15..bbebeaa4965 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -365,13 +365,14 @@ export const layer: Layer.Layer< } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { - if (Instance.project.vcs !== "git") { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) - const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } @@ -389,9 +390,9 @@ export const layer: Layer.Layer< } yield* stopFsmonitor(entry.path) - const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree }) + const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree }) if (removed.code !== 0) { - const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (next.code !== 0) { throw new RemoveFailedError({ message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", @@ -408,7 +409,7 @@ export const layer: Layer.Layer< const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree }) + const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree }) if (deleted.code !== 0) { throw new RemoveFailedError({ message: deleted.stderr || deleted.text || "Failed to delete worktree branch", @@ -498,17 +499,18 @@ export const layer: Layer.Layer< }) const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { - if (Instance.project.vcs !== "git") { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) - const primary = yield* canonical(Instance.worktree) + const primary = yield* canonical(ctx.worktree) if (directory === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } @@ -520,7 +522,7 @@ export const layer: Layer.Layer< const worktreePath = entry.path - const base = yield* gitSvc.defaultBranch(Instance.worktree) + const base = yield* gitSvc.defaultBranch(ctx.worktree) if (!base) { throw new ResetFailedError({ message: "Default branch not found" }) } @@ -531,7 +533,7 @@ export const layer: Layer.Layer< const branch = base.ref.slice(sep + 1) yield* gitExpect( ["fetch", remote, branch], - { cwd: Instance.worktree }, + { cwd: ctx.worktree }, (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }), ) } @@ -574,7 +576,7 @@ export const layer: Layer.Layer< throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) } - yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe( + yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe( Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))), Effect.forkIn(scope), ) From a6a4350d1032da8b1ae1ae1a51f11b7a0844fcff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:05:06 -0400 Subject: [PATCH 075/130] refactor(config): migrate permission.ts Info to Effect Schema (#23231) --- packages/opencode/src/config/permission.ts | 85 ++++++++++++---------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 7cfbaec01f7..eb3ac412bd4 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,16 +1,8 @@ export * as ConfigPermission from "./permission" import { Schema } from "effect" -import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod, ZodPreprocess } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const permissionPreprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __originalKeys: globalThis.Object.keys(val), ...val } - } - return val -} - export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -26,6 +18,48 @@ export const Rule = Schema.Union([Action, Object]) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Rule = Schema.Schema.Type +// Captures the user's original property insertion order before Schema.Struct +// canonicalises the object. See the `ZodPreprocess` comment in +// `util/effect-zod.ts` for the full rationale — in short: rule precedence is +// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win) +// and `Schema.StructWithRest` would otherwise drop that order. +const permissionPreprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + return { __originalKeys: globalThis.Object.keys(val), ...val } + } + return val +} + +const ObjectShape = Schema.StructWithRest( + Schema.Struct({ + __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + read: Schema.optional(Rule), + edit: Schema.optional(Rule), + glob: Schema.optional(Rule), + grep: Schema.optional(Rule), + list: Schema.optional(Rule), + bash: Schema.optional(Rule), + task: Schema.optional(Rule), + external_directory: Schema.optional(Rule), + todowrite: Schema.optional(Action), + question: Schema.optional(Action), + webfetch: Schema.optional(Action), + websearch: Schema.optional(Action), + codesearch: Schema.optional(Action), + lsp: Schema.optional(Rule), + doom_loop: Schema.optional(Action), + skill: Schema.optional(Rule), + }), + [Schema.Record(Schema.String, Rule)], +) + +const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({ + [ZodPreprocess]: permissionPreprocess, +}) + +// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the +// user's original insertion order. A plain string input (the Action branch of +// the union) becomes `{ "*": action }`. const transform = (x: unknown): Record => { if (typeof x === "string") return { "*": x as Action } const obj = x as { __originalKeys?: string[] } & Record @@ -38,34 +72,7 @@ const transform = (x: unknown): Record => { return result } -export const Info = z - .preprocess( - permissionPreprocess, - z - .object({ - __originalKeys: z.string().array().optional(), - read: Rule.zod.optional(), - edit: Rule.zod.optional(), - glob: Rule.zod.optional(), - grep: Rule.zod.optional(), - list: Rule.zod.optional(), - bash: Rule.zod.optional(), - task: Rule.zod.optional(), - external_directory: Rule.zod.optional(), - todowrite: Action.zod.optional(), - question: Action.zod.optional(), - webfetch: Action.zod.optional(), - websearch: Action.zod.optional(), - codesearch: Action.zod.optional(), - lsp: Rule.zod.optional(), - doom_loop: Action.zod.optional(), - skill: Rule.zod.optional(), - }) - .catchall(Rule.zod) - .or(Action.zod), - ) +export const Info = zod(InnerSchema) .transform(transform) - .meta({ - ref: "PermissionConfig", - }) -export type Info = z.infer + .meta({ ref: "PermissionConfig" }) +export type Info = Record From 9f7bd0246c14620758865513ceddfe0a768bec2e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 03:05:59 +0000 Subject: [PATCH 076/130] chore: generate --- packages/opencode/src/config/permission.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index eb3ac412bd4..d4883ed8c12 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -72,7 +72,5 @@ const transform = (x: unknown): Record => { return result } -export const Info = zod(InnerSchema) - .transform(transform) - .meta({ ref: "PermissionConfig" }) +export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" }) export type Info = Record From 2793502db20ffc88c18fdec91133ac157d470461 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:16:24 -0400 Subject: [PATCH 077/130] refactor(config): migrate agent.ts Info to Effect Schema (#23237) --- packages/opencode/src/config/agent.ts | 179 ++++++++++++++------------ 1 file changed, 98 insertions(+), 81 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 9053b19fc11..32dbc741027 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -1,10 +1,12 @@ export * as ConfigAgent from "./agent" -import { Log } from "../util" +import { Schema } from "effect" import z from "zod" +import { Bus } from "@/bus" +import { zod, ZodOverride } from "@/util/effect-zod" +import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" -import { Bus } from "@/bus" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" @@ -13,89 +15,104 @@ import { ConfigPermission } from "./permission" const log = Log.create({ service: "config" }) -export const Info = z - .object({ - model: ConfigModelID.zod.optional(), - variant: z - .string() - .optional() - .describe("Default model variant for this agent (applies only when using the agent's configured model)."), - temperature: z.number().optional(), - top_p: z.number().optional(), - prompt: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), - disable: z.boolean().optional(), - description: z.string().optional().describe("Description of when to use the agent"), - mode: z.enum(["subagent", "primary", "all"]).optional(), - hidden: z - .boolean() - .optional() - .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), - options: z.record(z.string(), z.any()).optional(), - color: z - .union([ - z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), - z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]), - ]) - .optional() - .describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"), - steps: z - .number() - .int() - .positive() - .optional() - .describe("Maximum number of agentic iterations before forcing text-only response"), - maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), - permission: ConfigPermission.Info.optional(), - }) - .catchall(z.any()) - .transform((agent, _ctx) => { - const knownKeys = new Set([ - "name", - "model", - "variant", - "prompt", - "description", - "temperature", - "top_p", - "mode", - "hidden", - "color", - "steps", - "maxSteps", - "options", - "permission", - "disable", - "tools", - ]) +const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) - const options: Record = { ...agent.options } - for (const [key, value] of Object.entries(agent)) { - if (!knownKeys.has(key)) options[key] = value +const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) + +// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)` +// shape lives outside the Effect Schema type system), so the walker reaches it +// via ZodOverride rather than a pure Schema reference. This preserves the +// `$ref: PermissionConfig` emitted in openapi.json. +const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) + +const AgentSchema = Schema.StructWithRest( + Schema.Struct({ + model: Schema.optional(ConfigModelID), + variant: Schema.optional(Schema.String).annotate({ + description: "Default model variant for this agent (applies only when using the agent's configured model).", + }), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + prompt: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ + description: "@deprecated Use 'permission' field instead", + }), + disable: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }), + mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])), + hidden: Schema.optional(Schema.Boolean).annotate({ + description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", + }), + options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + color: Schema.optional(Color).annotate({ + description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", + }), + steps: Schema.optional(PositiveInt).annotate({ + description: "Maximum number of agentic iterations before forcing text-only response", + }), + maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }), + permission: Schema.optional(PermissionRef), + }), + [Schema.Record(Schema.String, Schema.Any)], +) + +const KNOWN_KEYS = new Set([ + "name", + "model", + "variant", + "prompt", + "description", + "temperature", + "top_p", + "mode", + "hidden", + "color", + "steps", + "maxSteps", + "options", + "permission", + "disable", + "tools", +]) + +// Post-parse normalisation: +// - Promote any unknown-but-present keys into `options` so they survive the +// round-trip in a well-known field. +// - Translate the deprecated `tools: { name: boolean }` map into the new +// `permission` shape (write-adjacent tools collapse into `permission.edit`). +// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias. +const normalize = (agent: z.infer) => { + const options: Record = { ...agent.options } + for (const [key, value] of Object.entries(agent)) { + if (!KNOWN_KEYS.has(key)) options[key] = value + } + + const permission: ConfigPermission.Info = {} + for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { + const action = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + permission.edit = action + continue } + permission[tool] = action + } + globalThis.Object.assign(permission, agent.permission) - const permission: ConfigPermission.Info = {} - for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { - const action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { - permission.edit = action - continue - } - permission[tool] = action - } - Object.assign(permission, agent.permission) + return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps } +} - const steps = agent.steps ?? agent.maxSteps - - return { ...agent, options, permission, steps } as typeof agent & { - options?: Record - permission?: ConfigPermission.Info - steps?: number - } - }) - .meta({ - ref: "AgentConfig", - }) +export const Info = zod(AgentSchema) + .transform(normalize) + .meta({ ref: "AgentConfig" }) as unknown as z.ZodType< + Omit>>, "options" | "permission" | "steps"> & { + options?: Record + permission?: ConfigPermission.Info + steps?: number + } +> export type Info = z.infer export async function load(dir: string) { From 8a1e85d0c827b138a3af94f233a343cf8fbcccf2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 03:17:28 +0000 Subject: [PATCH 078/130] chore: generate --- packages/opencode/src/config/agent.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 32dbc741027..1469522d981 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -104,9 +104,7 @@ const normalize = (agent: z.infer) => { return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps } } -export const Info = zod(AgentSchema) - .transform(normalize) - .meta({ ref: "AgentConfig" }) as unknown as z.ZodType< +export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType< Omit>>, "options" | "permission" | "steps"> & { options?: Record permission?: ConfigPermission.Info From c0eab9e44285596066555688057335cd58a1f84e Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:31:38 +0200 Subject: [PATCH 079/130] fix(desktop): adjust ui tool diff sticky header offset (#23149) --- packages/ui/src/components/message-part.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a47ff180453..9c0c90c0007 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1269,7 +1269,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -2061,7 +2061,7 @@ ToolRegistry.register({ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > From 23f31475e7e8a5dce16b8095a45e954774ea65ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:44:35 -0400 Subject: [PATCH 080/130] refactor(config): migrate config.ts root Info to Effect Schema (#23241) --- packages/opencode/src/config/config.ts | 317 ++++++++++++++----------- 1 file changed, 174 insertions(+), 143 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 179c6a60939..248351e1a58 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -21,9 +21,10 @@ import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" -import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" +import { zod, ZodOverride } from "@/util/effect-zod" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -79,152 +80,182 @@ export const Server = ConfigServer.Server.zod export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout -export const Info = z - .object({ - $schema: z.string().optional().describe("JSON schema reference for configuration validation"), - logLevel: Log.Level.optional().describe("Log level"), - server: Server.optional().describe("Server configuration for opencode serve and web commands"), - command: z - .record(z.string(), ConfigCommand.Info.zod) - .optional() - .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), - watcher: z - .object({ - ignore: z.array(z.string()).optional(), - }) - .optional(), - snapshot: z - .boolean() - .optional() - .describe( - "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", - ), - // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. - plugin: ConfigPlugin.Spec.zod.array().optional(), - share: z - .enum(["manual", "auto", "disabled"]) - .optional() - .describe( - "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", - ), - autoshare: z - .boolean() - .optional() - .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), - autoupdate: z - .union([z.boolean(), z.literal("notify")]) - .optional() - .describe( - "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", - ), - disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), - enabled_providers: z - .array(z.string()) - .optional() - .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), - small_model: ConfigModelID.zod - .describe("Small model to use for tasks like title generation in the format of provider/model") - .optional(), - default_agent: z - .string() - .optional() - .describe( - "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", - ), - username: z.string().optional().describe("Custom username to display in conversations instead of system username"), - mode: z - .object({ - build: ConfigAgent.Info.optional(), - plan: ConfigAgent.Info.optional(), - }) - .catchall(ConfigAgent.Info) - .optional() - .describe("@deprecated Use `agent` field instead."), - agent: z - .object({ +// Schemas that still live at the zod layer (have .transform / .preprocess / +// .meta not expressible in current Effect Schema) get referenced via a +// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the +// exact zod directly, preserving component $refs. +const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info }) +const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) +const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level }) + +const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) +const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) + +const InfoSchema = Schema.Struct({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }), + server: Schema.optional(ConfigServer.Server).annotate({ + description: "Server configuration for opencode serve and web commands", + }), + command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({ + description: "Command configuration, see https://opencode.ai/docs/commands", + }), + skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), + watcher: Schema.optional( + Schema.Struct({ + ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + }), + ), + snapshot: Schema.optional(Schema.Boolean).annotate({ + description: + "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", + }), + // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. + plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))), + share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({ + description: + "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", + }), + autoshare: Schema.optional(Schema.Boolean).annotate({ + description: "@deprecated Use 'share' field instead. Share newly created sessions automatically", + }), + autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({ + description: + "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", + }), + disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Disable providers that are loaded automatically", + }), + enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "When set, ONLY these providers will be enabled. All other providers will be ignored", + }), + model: Schema.optional(ConfigModelID).annotate({ + description: "Model to use in the format of provider/model, eg anthropic/claude-2", + }), + small_model: Schema.optional(ConfigModelID).annotate({ + description: "Small model to use for tasks like title generation in the format of provider/model", + }), + default_agent: Schema.optional(Schema.String).annotate({ + description: + "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + }), + username: Schema.optional(Schema.String).annotate({ + description: "Custom username to display in conversations instead of system username", + }), + mode: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ + build: Schema.optional(AgentRef), + plan: Schema.optional(AgentRef), + }), + [Schema.Record(Schema.String, AgentRef)], + ), + ).annotate({ description: "@deprecated Use `agent` field instead." }), + agent: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ // primary - plan: ConfigAgent.Info.optional(), - build: ConfigAgent.Info.optional(), + plan: Schema.optional(AgentRef), + build: Schema.optional(AgentRef), // subagent - general: ConfigAgent.Info.optional(), - explore: ConfigAgent.Info.optional(), + general: Schema.optional(AgentRef), + explore: Schema.optional(AgentRef), // specialized - title: ConfigAgent.Info.optional(), - summary: ConfigAgent.Info.optional(), - compaction: ConfigAgent.Info.optional(), - }) - .catchall(ConfigAgent.Info) - .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), - provider: z - .record(z.string(), ConfigProvider.Info.zod) - .optional() - .describe("Custom provider configurations and model overrides"), - mcp: z - .record( - z.string(), - z.union([ - ConfigMCP.Info.zod, - z - .object({ - enabled: z.boolean(), - }) - .strict(), - ]), - ) - .optional() - .describe("MCP (Model Context Protocol) server configurations"), - formatter: ConfigFormatter.Info.zod.optional(), - lsp: ConfigLSP.Info.zod.optional(), - instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), - layout: Layout.optional().describe("@deprecated Always uses stretch layout."), - permission: ConfigPermission.Info.optional(), - tools: z.record(z.string(), z.boolean()).optional(), - enterprise: z - .object({ - url: z.string().optional().describe("Enterprise URL"), - }) - .optional(), - compaction: z - .object({ - auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), - prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), - reserved: z - .number() - .int() - .min(0) - .optional() - .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), - }) - .optional(), - experimental: z - .object({ - disable_paste_summary: z.boolean().optional(), - batch_tool: z.boolean().optional().describe("Enable the batch tool"), - openTelemetry: z - .boolean() - .optional() - .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), - primary_tools: z - .array(z.string()) - .optional() - .describe("Tools that should only be available to primary agents."), - continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), - mcp_timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for model context protocol (MCP) requests"), - }) - .optional(), - }) + title: Schema.optional(AgentRef), + summary: Schema.optional(AgentRef), + compaction: Schema.optional(AgentRef), + }), + [Schema.Record(Schema.String, AgentRef)], + ), + ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }), + provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({ + description: "Custom provider configurations and model overrides", + }), + mcp: Schema.optional( + Schema.Record( + Schema.String, + Schema.Union([ + ConfigMCP.Info, + // Matches the legacy `{ enabled: false }` form used to disable a server. + Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }), + ]), + ), + ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), + formatter: Schema.optional(ConfigFormatter.Info), + lsp: Schema.optional(ConfigLSP.Info), + instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional instruction files or patterns to include", + }), + layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), + permission: Schema.optional(PermissionRef), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + enterprise: Schema.optional( + Schema.Struct({ + url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), + }), + ), + compaction: Schema.optional( + Schema.Struct({ + auto: Schema.optional(Schema.Boolean).annotate({ + description: "Enable automatic compaction when context is full (default: true)", + }), + prune: Schema.optional(Schema.Boolean).annotate({ + description: "Enable pruning of old tool outputs (default: true)", + }), + reserved: Schema.optional(NonNegativeInt).annotate({ + description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", + }), + }), + ), + experimental: Schema.optional( + Schema.Struct({ + disable_paste_summary: Schema.optional(Schema.Boolean), + batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), + openTelemetry: Schema.optional(Schema.Boolean).annotate({ + description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", + }), + primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Tools that should only be available to primary agents.", + }), + continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ + description: "Continue the agent loop when a tool call is denied", + }), + mcp_timeout: Schema.optional(PositiveInt).annotate({ + description: "Timeout in milliseconds for model context protocol (MCP) requests", + }), + }), + ), +}) + +// Schema.Struct produces readonly types by default, but the service code +// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the +// readonly recursively so callers get the same mutable shape zod inferred. +// +// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback +// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}` +// (since `keyof unknown = never`), which widens `Record` +// fields like `ConfigPlugin.Options`. The local version gates on +// `extends object` so `unknown` passes through. +// +// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]` +// shape (otherwise the general array branch widens it to an array). +type DeepMutable = T extends readonly [unknown, ...unknown[]] + ? { -readonly [K in keyof T]: DeepMutable } + : T extends readonly (infer U)[] + ? DeepMutable[] + : T extends object + ? { -readonly [K in keyof T]: DeepMutable } + : T + +// The walker emits `z.object({...})` which is non-strict by default. Config +// historically uses `.strict()` (additionalProperties: false in openapi.json), +// so layer that on after derivation. Re-apply the Config ref afterward +// since `.strict()` strips the walker's meta annotation. +export const Info = (zod(InfoSchema) as unknown as z.ZodObject) .strict() - .meta({ - ref: "Config", - }) + .meta({ ref: "Config" }) as unknown as z.ZodType>> export type Info = z.output & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together From b382d1a4677622f94c9927ef3ccbd38e74bd5799 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 23:51:30 -0400 Subject: [PATCH 081/130] docs(effect): track schema migration progress with concrete file checklists (#23242) --- packages/opencode/specs/effect/schema.md | 278 ++++++++++++++++++++--- 1 file changed, 252 insertions(+), 26 deletions(-) diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index eed69e52b77..2cf80b98ee4 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,12 +1,19 @@ # Schema migration -Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims. +Practical reference for migrating data types in `packages/opencode` from +Zod-first definitions to Effect Schema with Zod compatibility shims. ## Goal -Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. +Use Effect Schema as the source of truth for domain models, IDs, inputs, +outputs, and typed errors. Keep Zod available at existing HTTP, tool, and +compatibility boundaries by exposing a `.zod` static derived from the Effect +schema via `@/util/effect-zod`. -Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema. +The long-term driver is `specs/effect/http-api.md` — once the HTTP server +moves to `@effect/platform`, every Schema-first DTO can flow through +`HttpApi` / `HttpRouter` without a zod translation layer, and the entire +`effect-zod` walker plus every `.zod` static can be deleted. ## Preferred shapes @@ -24,17 +31,14 @@ export class Info extends Schema.Class("Foo.Info")({ } ``` -If the class cannot reference itself cleanly during initialization, use the existing two-step pattern: +If the class cannot reference itself cleanly during initialization, use the +two-step `withStatics` pattern: ```ts -const _Info = Schema.Struct({ +export const Info = Schema.Struct({ id: FooID, name: Schema.String, -}) - -export const Info = Object.assign(_Info, { - zod: zod(_Info), -}) +}).pipe(withStatics((s) => ({ zod: zod(s) }))) ``` ### Errors @@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Foo ### IDs and branded leaf types -Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod. +Keep branded/schema-backed IDs as Effect schemas and expose +`static readonly zod` for compatibility when callers still expect Zod. + +### Refinements + +Reuse named refinements instead of re-spelling `z.number().int().positive()` +in every schema. The `effect-zod` walker translates the Effect versions into +the corresponding zod methods, so JSON Schema output (`type: integer`, +`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved. + +```ts +const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) +const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) +const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) +``` + +See `test/util/effect-zod.test.ts` for the full set of translated checks. ## Compatibility rule -During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema. +During migration, route validators, tool parameters, and any existing +Zod-based boundary should consume the derived `.zod` schema instead of +maintaining a second hand-written Zod schema. The default should be: - Effect Schema owns the type - `.zod` exists only as a compatibility surface -- new domain models should not start Zod-first unless there is a concrete boundary-specific need +- new domain models should not start Zod-first unless there is a concrete + boundary-specific need ## When Zod can stay It is fine to keep a Zod-native schema temporarily when: -- the type is only used at an HTTP or tool boundary +- the type is only used at an HTTP or tool boundary and is not reused elsewhere - the validator depends on Zod-only transforms or behavior not yet covered by `zod()` - the migration would force unrelated churn across a large call graph -When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth. +When this happens, prefer leaving a short note or TODO rather than silently +creating a parallel schema source of truth. + +## Escape hatches + +The walker in `@/util/effect-zod` exposes three explicit escape hatches for +cases the pure-Schema path cannot express. Each one stays in the codebase +only as long as its upstream or local dependency requires it — inline +comments document when each can be deleted. + +### `ZodOverride` annotation + +Replaces the entire derivation with a hand-crafted zod schema. Used when: + +- the target carries external `$ref` metadata (e.g. + `config/model-id.ts` points at `https://models.dev/...`) +- the target is a zod-only schema that cannot yet be expressed as Schema + (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`) + +### `ZodPreprocess` annotation + +Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by +`config/permission.ts` to inject `__originalKeys` before parsing, because +`Schema.StructWithRest` canonicalises output (known fields first, catchall +after) and destroys the user's original property order — which permission +rule precedence depends on. + +Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder +(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and +the `__originalKeys` hack can both be deleted. + +### Local `DeepMutable` in `config/config.ts` + +`Schema.Struct` produces `readonly` types. Some consumer code (notably the +`Config` service) mutates `Info` objects directly, so a readonly-stripping +utility is needed when casting the derived zod schema's output type. + +`Types.DeepMutable` from effect-smol would be a drop-in, but it widens +`unknown` to `{}` in the fallback branch — a bug that affects any schema +using `Schema.Record(String, Schema.Unknown)`. + +Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown +to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and +`Types.DeepMutable` used directly. ## Ordering @@ -81,19 +147,179 @@ Migrate in this order: 4. Service-local internal models 5. Route and tool boundary validators that can switch to `.zod` -This keeps shared types canonical first and makes boundary updates mostly mechanical. +This keeps shared types canonical first and makes boundary updates mostly +mechanical. -## Checklist +## Progress tracker -- [ ] Shared `schema.ts` leaf models are Effect Schema-first -- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate -- [ ] Domain errors use `Schema.TaggedErrorClass` -- [ ] Migrated types expose `.zod` for back compatibility -- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions -- [ ] New domain models default to Effect Schema first +### `src/config/` ✅ complete + +All of `packages/opencode/src/config/` has been migrated. Files that still +import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` +type annotations — the `export const ` values are all Effect +Schema at source. + +- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider +- [x] server, layout +- [x] keybinds +- [x] permission#Info +- [x] agent +- [x] config.ts root + +### `src/*/schema.ts` leaf modules + +These are the highest-priority next targets. Each is a small, self-contained +schema module with a clear domain. + +- [ ] `src/control-plane/schema.ts` +- [ ] `src/permission/schema.ts` +- [ ] `src/project/schema.ts` +- [ ] `src/provider/schema.ts` +- [ ] `src/pty/schema.ts` +- [ ] `src/question/schema.ts` +- [ ] `src/session/schema.ts` +- [ ] `src/sync/schema.ts` +- [ ] `src/tool/schema.ts` + +### Session domain + +Major cluster. Message + event types flow through the SSE API and every SDK +output, so byte-identical SDK surface is critical. + +- [ ] `src/session/compaction.ts` +- [ ] `src/session/message-v2.ts` +- [ ] `src/session/message.ts` +- [ ] `src/session/prompt.ts` +- [ ] `src/session/revert.ts` +- [ ] `src/session/session.ts` +- [ ] `src/session/status.ts` +- [ ] `src/session/summary.ts` +- [ ] `src/session/todo.ts` + +### Provider domain + +- [ ] `src/provider/auth.ts` +- [ ] `src/provider/models.ts` +- [ ] `src/provider/provider.ts` + +### Tool schemas + +Each tool declares its parameters via a zod schema. Tools are consumed by +both the in-process runtime and the AI SDK's tool-calling layer, so the +emitted JSON Schema must stay byte-identical. + +- [ ] `src/tool/apply_patch.ts` +- [ ] `src/tool/bash.ts` +- [ ] `src/tool/codesearch.ts` +- [ ] `src/tool/edit.ts` +- [ ] `src/tool/glob.ts` +- [ ] `src/tool/grep.ts` +- [ ] `src/tool/invalid.ts` +- [ ] `src/tool/lsp.ts` +- [ ] `src/tool/multiedit.ts` +- [ ] `src/tool/plan.ts` +- [ ] `src/tool/question.ts` +- [ ] `src/tool/read.ts` +- [ ] `src/tool/registry.ts` +- [ ] `src/tool/skill.ts` +- [ ] `src/tool/task.ts` +- [ ] `src/tool/todo.ts` +- [ ] `src/tool/tool.ts` +- [ ] `src/tool/webfetch.ts` +- [ ] `src/tool/websearch.ts` +- [ ] `src/tool/write.ts` + +### HTTP route boundaries + +Every file in `src/server/routes/` uses hono-openapi with zod validators for +route inputs/outputs. Migrating these individually is the last step; most +will switch to `.zod` derived from the Schema-migrated domain types above, +which means touching them is largely mechanical once the domain side is +done. + +- [ ] `src/server/error.ts` +- [ ] `src/server/event.ts` +- [ ] `src/server/projectors.ts` +- [ ] `src/server/routes/control/index.ts` +- [ ] `src/server/routes/control/workspace.ts` +- [ ] `src/server/routes/global.ts` +- [ ] `src/server/routes/instance/index.ts` +- [ ] `src/server/routes/instance/config.ts` +- [ ] `src/server/routes/instance/event.ts` +- [ ] `src/server/routes/instance/experimental.ts` +- [ ] `src/server/routes/instance/file.ts` +- [ ] `src/server/routes/instance/mcp.ts` +- [ ] `src/server/routes/instance/permission.ts` +- [ ] `src/server/routes/instance/project.ts` +- [ ] `src/server/routes/instance/provider.ts` +- [ ] `src/server/routes/instance/pty.ts` +- [ ] `src/server/routes/instance/question.ts` +- [ ] `src/server/routes/instance/session.ts` +- [ ] `src/server/routes/instance/sync.ts` +- [ ] `src/server/routes/instance/tui.ts` + +The bigger prize for this group is the `@effect/platform` HTTP migration +described in `specs/effect/http-api.md`. Once that lands, every one of +these files changes shape entirely (`HttpApi.endpoint(...)` and friends), +so the Schema-first domain types become a prerequisite rather than a +sibling task. + +### Everything else + +Small / shared / control-plane / CLI. Mostly independent; can be done +piecewise. + +- [ ] `src/acp/agent.ts` +- [ ] `src/agent/agent.ts` +- [ ] `src/bus/bus-event.ts` +- [ ] `src/bus/index.ts` +- [ ] `src/cli/cmd/tui/config/tui-migrate.ts` +- [ ] `src/cli/cmd/tui/config/tui-schema.ts` +- [ ] `src/cli/cmd/tui/config/tui.ts` +- [ ] `src/cli/cmd/tui/event.ts` +- [ ] `src/cli/ui.ts` +- [ ] `src/command/index.ts` +- [ ] `src/control-plane/adaptors/worktree.ts` +- [ ] `src/control-plane/types.ts` +- [ ] `src/control-plane/workspace.ts` +- [ ] `src/file/index.ts` +- [ ] `src/file/ripgrep.ts` +- [ ] `src/file/watcher.ts` +- [ ] `src/format/index.ts` +- [ ] `src/id/id.ts` +- [ ] `src/ide/index.ts` +- [ ] `src/installation/index.ts` +- [ ] `src/lsp/client.ts` +- [ ] `src/lsp/lsp.ts` +- [ ] `src/mcp/auth.ts` +- [ ] `src/patch/index.ts` +- [ ] `src/plugin/github-copilot/models.ts` +- [ ] `src/project/project.ts` +- [ ] `src/project/vcs.ts` +- [ ] `src/pty/index.ts` +- [ ] `src/skill/index.ts` +- [ ] `src/snapshot/index.ts` +- [ ] `src/storage/db.ts` +- [ ] `src/storage/storage.ts` +- [ ] `src/sync/index.ts` +- [ ] `src/util/fn.ts` +- [ ] `src/util/log.ts` +- [ ] `src/util/update-schema.ts` +- [ ] `src/worktree/index.ts` + +### Do-not-migrate + +- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever + (it's what emits zod from Schema). Goes away only when the `.zod` + compatibility layer is no longer needed anywhere. ## Notes -- Use `@/util/effect-zod` for all Schema -> Zod conversion. -- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. -- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change. +- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Prefer one canonical schema definition. Avoid maintaining parallel Zod and + Effect definitions for the same domain type. +- Keep the migration incremental. Converting the domain model first is more + valuable than converting every boundary in the same change. +- Every migrated file should leave the generated SDK output (`packages/sdk/ + openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical + unless the change is deliberately user-visible. From 5e9d5c734ea883c00bdb2936e3bd6d786b220db4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 03:52:28 +0000 Subject: [PATCH 082/130] chore: generate --- packages/opencode/specs/effect/schema.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 2cf80b98ee4..72ee10350dd 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -64,9 +64,9 @@ the corresponding zod methods, so JSON Schema output (`type: integer`, `exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved. ```ts -const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) +const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) -const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) +const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) ``` See `test/util/effect-zod.test.ts` for the full set of translated checks. @@ -321,5 +321,5 @@ piecewise. - Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change. - Every migrated file should leave the generated SDK output (`packages/sdk/ - openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical +openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical unless the change is deliberately user-visible. From 9c16bd1e30d631d482ae696f426bf5f7eb73dbdb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:51:16 -0500 Subject: [PATCH 083/130] fix: make skills logic more token efficient (#23253) --- packages/opencode/src/tool/skill.ts | 127 +++++++++------------- packages/opencode/src/tool/skill.txt | 5 + packages/opencode/test/tool/skill.test.ts | 96 ---------------- 3 files changed, 57 insertions(+), 171 deletions(-) create mode 100644 packages/opencode/src/tool/skill.txt diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 58a66ee7443..d86faec2b4f 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,10 +3,10 @@ import { pathToFileURL } from "url" import z from "zod" import { Effect } from "effect" import * as Stream from "effect/Stream" -import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" +import DESCRIPTION from "./skill.txt" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), @@ -18,82 +18,59 @@ export const SkillTool = Tool.define( const skill = yield* Skill.Service const rg = yield* Ripgrep.Service - return () => - Effect.gen(function* () { - const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer)) + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* skill.get(params.name) + if (!info) { + const all = yield* skill.all() + const available = all.map((item) => item.name).join(", ") + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } - const description = - list.length === 0 - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." - : [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") + yield* ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }) - return { - description, - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => - Effect.gen(function* () { - const info = yield* skill.get(params.name) - if (!info) { - const all = yield* skill.all() - const available = all.map((item) => item.name).join(", ") - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } + const dir = path.dirname(info.location) + const base = pathToFileURL(dir).href + const limit = 10 + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( + Stream.filter((file) => !file.includes("SKILL.md")), + Stream.map((file) => path.resolve(dir, file)), + Stream.take(limit), + Stream.runCollect, + Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), + ) - yield* ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }) - - const dir = path.dirname(info.location) - const base = pathToFileURL(dir).href - const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( - Stream.filter((file) => !file.includes("SKILL.md")), - Stream.map((file) => path.resolve(dir, file)), - Stream.take(limit), - Stream.runCollect, - Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), - ) - - return { - title: `Loaded skill: ${info.name}`, - output: [ - ``, - `# Skill: ${info.name}`, - "", - info.content.trim(), - "", - `Base directory for this skill: ${base}`, - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - files, - "", - "", - ].join("\n"), - metadata: { - name: info.name, - dir, - }, - } - }).pipe(Effect.orDie), - } - }) + return { + title: `Loaded skill: ${info.name}`, + output: [ + ``, + `# Skill: ${info.name}`, + "", + info.content.trim(), + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + files, + "", + "", + ].join("\n"), + metadata: { + name: info.name, + dir, + }, + } + }).pipe(Effect.orDie), + } }), ) diff --git a/packages/opencode/src/tool/skill.txt b/packages/opencode/src/tool/skill.txt new file mode 100644 index 00000000000..44d990317ad --- /dev/null +++ b/packages/opencode/src/tool/skill.txt @@ -0,0 +1,5 @@ +Load a specialized skill when the task at hand matches one of the skills listed in the system prompt. + +Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill. + +The skill name must match one of the skills listed in your system prompt. diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 55e126ab47b..b12940e4dc2 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -31,102 +31,6 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) describe("tool.skill", () => { - it.live("description lists skill location URL", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const skill = path.join(dir, ".opencode", "skill", "tool-skill") - yield* Effect.promise(() => - Bun.write( - path.join(skill, "SKILL.md"), - `--- -name: tool-skill -description: Skill for tool tests. ---- - -# Tool Skill -`, - ), - ) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = dir - yield* Effect.addFinalizer(() => - Effect.sync(() => { - process.env.OPENCODE_TEST_HOME = home - }), - ) - const registry = yield* ToolRegistry.Service - const desc = - (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent: { name: "build", mode: "primary", permission: [], options: {} }, - })).find((tool) => tool.id === SkillTool.id)?.description ?? "" - expect(desc).toContain("**tool-skill**: Skill for tool tests.") - }), - { git: true }, - ), - ) - - it.live("description sorts skills by name and is stable across calls", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - for (const [name, description] of [ - ["zeta-skill", "Zeta skill."], - ["alpha-skill", "Alpha skill."], - ["middle-skill", "Middle skill."], - ]) { - const skill = path.join(dir, ".opencode", "skill", name) - yield* Effect.promise(() => - Bun.write( - path.join(skill, "SKILL.md"), - `--- -name: ${name} -description: ${description} ---- - -# ${name} -`, - ), - ) - } - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = dir - yield* Effect.addFinalizer(() => - Effect.sync(() => { - process.env.OPENCODE_TEST_HOME = home - }), - ) - - const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const registry = yield* ToolRegistry.Service - const load = Effect.fnUntraced(function* () { - return ( - (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent, - })).find((tool) => tool.id === SkillTool.id)?.description ?? "" - ) - }) - const first = yield* load() - const second = yield* load() - - expect(first).toBe(second) - - const alpha = first.indexOf("**alpha-skill**: Alpha skill.") - const middle = first.indexOf("**middle-skill**: Middle skill.") - const zeta = first.indexOf("**zeta-skill**: Zeta skill.") - - expect(alpha).toBeGreaterThan(-1) - expect(middle).toBeGreaterThan(alpha) - expect(zeta).toBeGreaterThan(middle) - }), - { git: true }, - ), - ) - it.live("execute returns skill content block with files", () => provideTmpdirInstance( (dir) => From 11cd4fb63904768da73fad323dc82a83e052f87c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 01:46:26 -0400 Subject: [PATCH 084/130] core: extract session entry stepping logic into dedicated module Move the step function from session-entry.ts to session-entry-stepper.ts and remove immer dependency. Add static fromEvent factory methods to Synthetic, Assistant, and Compaction classes for cleaner event-to-entry conversion. --- .../opencode/src/v2/session-entry-stepper.ts | 253 ++++++++++++++++++ packages/opencode/src/v2/session-entry.ts | 178 ++---------- ....test.ts => session-entry-stepper.test.ts} | 188 +++++++++++-- 3 files changed, 446 insertions(+), 173 deletions(-) create mode 100644 packages/opencode/src/v2/session-entry-stepper.ts rename packages/opencode/test/session/{session-entry.test.ts => session-entry-stepper.test.ts} (76%) diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts new file mode 100644 index 00000000000..3d642579d0d --- /dev/null +++ b/packages/opencode/src/v2/session-entry-stepper.ts @@ -0,0 +1,253 @@ +import { castDraft, produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionEntry } from "./session-entry" + +export type MemoryState = { + entries: SessionEntry.Entry[] + pending: SessionEntry.Entry[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined + readonly updateAssistant: (assistant: SessionEntry.Assistant) => void + readonly appendEntry: (entry: SessionEntry.Entry) => void + readonly appendPending: (entry: SessionEntry.Entry) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.entries[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.entries[index] + if (current?.type !== "assistant") return + state.entries[index] = assistant + }, + appendEntry(entry) { + state.entries.push(entry) + }, + appendPending(entry) { + state.pending.push(entry) + }, + finish() { + return state + }, + } +} + +export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") + + SessionEvent.Event.match(event, { + prompt: (event) => { + const entry = SessionEntry.User.fromEvent(event) + if (currentAssistant) { + adapter.appendPending(entry) + return + } + adapter.appendEntry(entry) + }, + synthetic: (event) => { + adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) + }, + "step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.timestamp + }), + ) + } + adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) + }, + "step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.timestamp + draft.cost = event.cost + draft.tokens = event.tokens + }), + ) + } + }, + "text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.delta + }), + ) + } + }, + "text.ended": () => {}, + "tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + callID: event.callID, + name: event.name, + time: { + created: event.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.delta + }), + ) + } + }, + "tool.input.ended": () => {}, + "tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.callID) + if (match) { + match.time.ran = event.timestamp + match.state = { + status: "running", + input: event.input, + } + } + }), + ) + } + }, + "tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.callID) + if (match && match.state.status === "running") { + match.state = { + status: "completed", + input: match.state.input, + output: event.output ?? "", + title: event.title, + metadata: event.metadata ?? {}, + attachments: [...(event.attachments ?? [])], + } + } + }), + ) + } + }, + "tool.error": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.callID) + if (match && match.state.status === "running") { + match.state = { + status: "error", + error: event.error, + input: match.state.input, + metadata: event.metadata ?? {}, + } + } + }), + ) + } + }, + "reasoning.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + text: "", + }) + }), + ) + } + }, + "reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft) + if (match) match.text += event.delta + }), + ) + } + }, + "reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft) + if (match) match.text = event.text + }), + ) + } + }, + retried: () => {}, + compacted: (event) => { + adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) + }, + }) + + return adapter.finish() +} + +export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { + return produce(old, (draft) => { + stepWith(memory(draft as MemoryState), event) + }) +} + +export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 08122428aeb..97c5fc7ce91 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" import { SessionEvent } from "./session-event" -import { castDraft, produce } from "immer" export const ID = SessionEvent.ID export type ID = Schema.Schema.Type @@ -40,7 +39,14 @@ export class Synthetic extends Schema.Class("Session.Entry.Synthetic" ...SessionEvent.Synthetic.fields, ...Base, type: Schema.Literal("synthetic"), -}) {} +}) { + static fromEvent(event: SessionEvent.Synthetic) { + return new Synthetic({ + ...event, + time: { created: event.timestamp }, + }) + } +} export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ status: Schema.Literal("pending"), @@ -122,13 +128,32 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" created: Schema.DateTimeUtc, completed: Schema.DateTimeUtc.pipe(Schema.optional), }), -}) {} +}) { + static fromEvent(event: SessionEvent.Step.Started) { + return new Assistant({ + id: event.id, + type: "assistant", + time: { + created: event.timestamp, + }, + content: [], + }) + } +} export class Compaction extends Schema.Class("Session.Entry.Compaction")({ ...SessionEvent.Compacted.fields, type: Schema.Literal("compaction"), ...Base, -}) {} +}) { + static fromEvent(event: SessionEvent.Compacted) { + return new Compaction({ + ...event, + type: "compaction", + time: { created: event.timestamp }, + }) + } +} export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) @@ -136,151 +161,6 @@ export type Entry = Schema.Schema.Type export type Type = Entry["type"] -export type History = { - entries: Entry[] - pending: Entry[] -} - -export function step(old: History, event: SessionEvent.Event): History { - return produce(old, (draft) => { - const lastAssistant = draft.entries.findLast((x) => x.type === "assistant") - const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined - type DraftContent = NonNullable["content"][number] - type DraftTool = Extract - - const latestTool = (callID?: string) => - pendingAssistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text") - const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = User.fromEvent(event) - if (pendingAssistant) { - draft.pending.push(castDraft(entry)) - return - } - draft.entries.push(castDraft(entry)) - }, - synthetic: (event) => { - draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } })) - }, - "step.started": (event) => { - if (pendingAssistant) pendingAssistant.time.completed = event.timestamp - draft.entries.push({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - }) - }, - "step.ended": (event) => { - if (!pendingAssistant) return - pendingAssistant.time.completed = event.timestamp - pendingAssistant.cost = event.cost - pendingAssistant.tokens = event.tokens - }, - "text.started": () => { - if (!pendingAssistant) return - pendingAssistant.content.push({ - type: "text", - text: "", - }) - }, - "text.delta": (event) => { - if (!pendingAssistant) return - const match = latestText() - if (match) match.text += event.delta - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (!pendingAssistant) return - pendingAssistant.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }, - "tool.input.delta": (event) => { - if (!pendingAssistant) return - const match = latestTool(event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match) match.state.input += event.delta - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (!pendingAssistant) return - const match = latestTool(event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }, - "tool.success": (event) => { - if (!pendingAssistant) return - const match = latestTool(event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }, - "tool.error": (event) => { - if (!pendingAssistant) return - const match = latestTool(event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }, - "reasoning.started": () => { - if (!pendingAssistant) return - pendingAssistant.content.push({ - type: "reasoning", - text: "", - }) - }, - "reasoning.delta": (event) => { - if (!pendingAssistant) return - const match = latestReasoning() - if (match) match.text += event.delta - }, - "reasoning.ended": (event) => { - if (!pendingAssistant) return - const match = latestReasoning() - if (match) match.text = event.text - }, - retried: () => {}, - compacted: (event) => { - draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } })) - }, - }) - }) -} - /* export interface Interface { readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry diff --git a/packages/opencode/test/session/session-entry.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts similarity index 76% rename from packages/opencode/test/session/session-entry.test.ts rename to packages/opencode/test/session/session-entry-stepper.test.ts index dea8da20a0c..a81b4c2be40 100644 --- a/packages/opencode/test/session/session-entry.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import * as FastCheck from "effect/testing/FastCheck" import { SessionEntry } from "../../src/v2/session-entry" +import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" import { SessionEvent } from "../../src/v2/session-event" const time = (n: number) => DateTime.makeUnsafe(n) @@ -29,8 +30,8 @@ function assistant() { }) } -function history() { - const state: SessionEntry.History = { +function memoryState() { + const state: SessionEntryStepper.MemoryState = { entries: [], pending: [], } @@ -38,51 +39,189 @@ function history() { } function active() { - const state: SessionEntry.History = { + const state: SessionEntryStepper.MemoryState = { entries: [assistant()], pending: [], } return state } -function run(events: SessionEvent.Event[], state = history()) { - return events.reduce((state, event) => SessionEntry.step(state, event), state) +function run(events: SessionEvent.Event[], state = memoryState()) { + return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) } -function last(state: SessionEntry.History) { +function last(state: SessionEntryStepper.MemoryState) { const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") expect(entry?.type).toBe("assistant") return entry?.type === "assistant" ? entry : undefined } -function texts_of(state: SessionEntry.History) { +function texts_of(state: SessionEntryStepper.MemoryState) { const entry = last(state) if (!entry) return [] return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") } -function reasons(state: SessionEntry.History) { +function reasons(state: SessionEntryStepper.MemoryState) { const entry = last(state) if (!entry) return [] return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") } -function tools(state: SessionEntry.History) { +function tools(state: SessionEntryStepper.MemoryState) { const entry = last(state) if (!entry) return [] return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") } -function tool(state: SessionEntry.History, callID: string) { +function tool(state: SessionEntryStepper.MemoryState, callID: string) { return tools(state).find((x) => x.callID === callID) } -describe("session-entry step", () => { - describe("seeded pending assistant", () => { +function adapterStore() { + return { + committed: [] as SessionEntry.Entry[], + deferred: [] as SessionEntry.Entry[], + } +} + +function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { + const activeAssistantIndex = () => + store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) + + const getCurrentAssistant = () => { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = store.committed[index] + return assistant?.type === "assistant" ? assistant : undefined + } + + return { + getCurrentAssistant, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = store.committed[index] + if (current?.type !== "assistant") return + store.committed[index] = assistant + }, + appendEntry(entry) { + store.committed.push(entry) + }, + appendPending(entry) { + store.deferred.push(entry) + }, + finish() { + return store + }, + } +} + +describe("session-entry-stepper", () => { + describe("stepWith", () => { + test("reduces through a custom adapter", () => { + const store = adapterStore() + store.committed.push(assistant()) + + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) })) + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) })) + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) + SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) })) + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Step.Ended.create({ + reason: "stop", + cost: 1, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { + read: 4, + write: 5, + }, + }, + timestamp: time(7), + }), + ) + + expect(store.deferred).toHaveLength(1) + expect(store.deferred[0]?.type).toBe("user") + expect(store.committed).toHaveLength(1) + expect(store.committed[0]?.type).toBe("assistant") + if (store.committed[0]?.type !== "assistant") return + + expect(store.committed[0].content).toEqual([ + { type: "reasoning", text: "thought" }, + { type: "text", text: "world" }, + ]) + expect(store.committed[0].time.completed).toEqual(time(7)) + }) + }) + + describe("memory", () => { + test("tracks and replaces the current assistant", () => { + const state = active() + const adapter = SessionEntryStepper.memory(state) + const current = adapter.getCurrentAssistant() + + expect(current?.type).toBe("assistant") + if (!current) return + + adapter.updateAssistant( + new SessionEntry.Assistant({ + ...current, + content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], + time: { + ...current.time, + completed: time(1), + }, + }), + ) + + expect(adapter.getCurrentAssistant()).toBeUndefined() + expect(state.entries[0]?.type).toBe("assistant") + if (state.entries[0]?.type !== "assistant") return + + expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) + expect(state.entries[0].time.completed).toEqual(time(1)) + }) + + test("appends committed and pending entries", () => { + const state = memoryState() + const adapter = SessionEntryStepper.memory(state) + const committed = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) })) + const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) + + adapter.appendEntry(committed) + adapter.appendPending(pending) + + expect(state.entries).toEqual([committed]) + expect(state.pending).toEqual([pending]) + }) + + test("stepWith through memory records reasoning", () => { + const state = active() + + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Started.create({ timestamp: time(1) })) + SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) })) + SessionEntryStepper.stepWith( + SessionEntryStepper.memory(state), + SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), + ) + + expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) + }) + }) + + describe("step", () => { + describe("seeded pending assistant", () => { test("stores prompts in entries when no assistant is pending", () => { FastCheck.assert( FastCheck.property(word, (body) => { - const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + const next = SessionEntryStepper.step(memoryState(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) expect(next.entries).toHaveLength(1) expect(next.entries[0]?.type).toBe("user") if (next.entries[0]?.type !== "user") return @@ -95,7 +234,7 @@ describe("session-entry step", () => { test("stores prompts in pending when an assistant is pending", () => { FastCheck.assert( FastCheck.property(word, (body) => { - const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + const next = SessionEntryStepper.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) expect(next.pending).toHaveLength(1) expect(next.pending[0]?.type).toBe("user") if (next.pending[0]?.type !== "user") return @@ -110,8 +249,8 @@ describe("session-entry step", () => { FastCheck.property(texts, (parts) => { const next = parts.reduce( (state, part, i) => - SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })), - SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), + SessionEntryStepper.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })), + SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), ) expect(texts_of(next)).toEqual([ @@ -302,7 +441,7 @@ describe("session-entry step", () => { }, timestamp: time(n), }) - const next = SessionEntry.step(active(), event) + const next = SessionEntryStepper.step(active(), event) const entry = last(next) expect(entry).toBeDefined() if (!entry) return @@ -316,12 +455,12 @@ describe("session-entry step", () => { }) }) - describe("known reducer gaps", () => { + describe("known reducer gaps", () => { test("prompt appends immutably when no assistant is pending", () => { FastCheck.assert( FastCheck.property(word, (body) => { - const old = history() - const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + const old = memoryState() + const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) expect(old).not.toBe(next) expect(old.entries).toHaveLength(0) expect(next.entries).toHaveLength(1) @@ -334,7 +473,7 @@ describe("session-entry step", () => { FastCheck.assert( FastCheck.property(word, (body) => { const old = active() - const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) expect(old).not.toBe(next) expect(old.pending).toHaveLength(0) expect(next.pending).toHaveLength(1) @@ -651,7 +790,7 @@ describe("session-entry step", () => { test("records synthetic events", () => { FastCheck.assert( FastCheck.property(word, (body) => { - const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) })) + const next = SessionEntryStepper.step(memoryState(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) })) expect(next.entries).toHaveLength(1) expect(next.entries[0]?.type).toBe("synthetic") if (next.entries[0]?.type !== "synthetic") return @@ -664,8 +803,8 @@ describe("session-entry step", () => { test("records compaction events", () => { FastCheck.assert( FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntry.step( - history(), + const next = SessionEntryStepper.step( + memoryState(), SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), ) expect(next.entries).toHaveLength(1) @@ -677,5 +816,6 @@ describe("session-entry step", () => { { numRuns: 50 }, ) }) + }) }) }) From 95edbc0ae68a5e4f9a24a5d0249391ef18b168f5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 05:49:37 +0000 Subject: [PATCH 085/130] chore: generate --- .../session/session-entry-stepper.test.ts | 1095 +++++++++-------- 1 file changed, 562 insertions(+), 533 deletions(-) diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index a81b4c2be40..5c7df2dbadb 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -125,10 +125,19 @@ describe("session-entry-stepper", () => { SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) })) + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), + ) + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), + ) SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) })) + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), + ) SessionEntryStepper.stepWith( adapterFor(store), SessionEvent.Step.Ended.create({ @@ -192,7 +201,9 @@ describe("session-entry-stepper", () => { test("appends committed and pending entries", () => { const state = memoryState() const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) })) + const committed = SessionEntry.User.fromEvent( + SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), + ) const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) adapter.appendEntry(committed) @@ -205,8 +216,14 @@ describe("session-entry-stepper", () => { test("stepWith through memory records reasoning", () => { const state = active() - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Started.create({ timestamp: time(1) })) - SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) })) + SessionEntryStepper.stepWith( + SessionEntryStepper.memory(state), + SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), + ) + SessionEntryStepper.stepWith( + SessionEntryStepper.memory(state), + SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), + ) SessionEntryStepper.stepWith( SessionEntryStepper.memory(state), SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), @@ -218,377 +235,283 @@ describe("session-entry-stepper", () => { describe("step", () => { describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step(memoryState(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) + test("stores prompts in entries when no assistant is pending", () => { + FastCheck.assert( + FastCheck.property(word, (body) => { + const next = SessionEntryStepper.step( + memoryState(), + SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), + ) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("user") + if (next.entries[0]?.type !== "user") return + expect(next.entries[0].text).toBe(body) + }), + { numRuns: 50 }, + ) + }) - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) + test("stores prompts in pending when an assistant is pending", () => { + FastCheck.assert( + FastCheck.property(word, (body) => { + const next = SessionEntryStepper.step( + active(), + SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), + ) + expect(next.pending).toHaveLength(1) + expect(next.pending[0]?.type).toBe("user") + if (next.pending[0]?.type !== "user") return + expect(next.pending[0].text).toBe(body) + }), + { numRuns: 50 }, + ) + }) - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) + test("accumulates text deltas on the latest text part", () => { + FastCheck.assert( + FastCheck.property(texts, (parts) => { + const next = parts.reduce( + (state, part, i) => + SessionEntryStepper.step( + state, + SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), + ), + SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), + ) - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), + expect(texts_of(next)).toEqual([ + { + type: "text", + text: parts.join(""), + }, + ]) + }), + { numRuns: 100 }, + ) + }) + + test("routes later text deltas to the latest text segment", () => { + FastCheck.assert( + FastCheck.property(texts, texts, (a, b) => { + const next = run( + [ + SessionEvent.Text.Started.create({ timestamp: time(1) }), + ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), + SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), + ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), + ], + active(), + ) + + expect(texts_of(next)).toEqual([ + { type: "text", text: a.join("") }, + { type: "text", text: b.join("") }, + ]) + }), + { numRuns: 50 }, + ) + }) + + test("reasoning.ended replaces buffered reasoning text", () => { + FastCheck.assert( + FastCheck.property(texts, text, (parts, end) => { + const next = run( + [ + SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), + ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), + SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), + ], + active(), + ) + + expect(reasons(next)).toEqual([ + { + type: "reasoning", + text: end, + }, + ]) + }), + { numRuns: 100 }, + ) + }) + + test("tool.success completes the latest running tool", () => { + FastCheck.assert( + FastCheck.property( + word, + word, + dict, + maybe(text), + maybe(dict), + maybe(files), + texts, + (callID, title, input, output, metadata, attachments, parts) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + ...parts.map((x, i) => + SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), + ), + SessionEvent.Tool.Called.create({ + callID, + tool: "bash", + input, + provider: { executed: true }, + timestamp: time(parts.length + 2), + }), + SessionEvent.Tool.Success.create({ + callID, + title, + output, + metadata, + attachments, + provider: { executed: true }, + timestamp: time(parts.length + 3), + }), + ], + active(), + ) + + const match = tool(next, callID) + expect(match?.state.status).toBe("completed") + if (match?.state.status !== "completed") return + + expect(match.time.ran).toEqual(time(parts.length + 2)) + expect(match.state.input).toEqual(input) + expect(match.state.output).toBe(output ?? "") + expect(match.state.title).toBe(title) + expect(match.state.metadata).toEqual(metadata ?? {}) + expect(match.state.attachments).toEqual(attachments ?? []) }, - ]) - }), - { numRuns: 100 }, - ) - }) + ), + { numRuns: 50 }, + ) + }) - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { + test("tool.error completes the latest running tool with an error", () => { + FastCheck.assert( + FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { const next = run( [ SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), SessionEvent.Tool.Called.create({ callID, tool: "bash", input, provider: { executed: true }, - timestamp: time(parts.length + 2), + timestamp: time(2), }), - SessionEvent.Tool.Success.create({ + SessionEvent.Tool.Error.create({ callID, - title, - output, + error, metadata, - attachments, provider: { executed: true }, - timestamp: time(parts.length + 3), + timestamp: time(3), }), ], active(), ) const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return + expect(match?.state.status).toBe("error") + if (match?.state.status !== "error") return - expect(match.time.ran).toEqual(time(parts.length + 2)) + expect(match.time.ran).toEqual(time(2)) expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) + expect(match.state.error).toBe(error) expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) + }), + { numRuns: 50 }, + ) + }) - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) + test("tool.success is ignored before tool.called promotes the tool to running", () => { + FastCheck.assert( + FastCheck.property(word, word, (callID, title) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Success.create({ + callID, + title, + provider: { executed: true }, + timestamp: time(2), + }), + ], + active(), + ) + const match = tool(next, callID) + expect(match?.state).toEqual({ + status: "pending", + input: "", + }) + }), + { numRuns: 50 }, + ) + }) - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, + test("step.ended copies completion fields onto the pending assistant", () => { + FastCheck.assert( + FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { + const event = SessionEvent.Step.Ended.create({ + reason: "stop", + cost: 1, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { + read: 4, + write: 5, + }, }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return + timestamp: time(n), + }) + const next = SessionEntryStepper.step(active(), event) + const entry = last(next) + expect(entry).toBeDefined() + if (!entry) return - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) + expect(entry.time.completed).toEqual(event.timestamp) + expect(entry.cost).toBe(event.cost) + expect(entry.tokens).toEqual(event.tokens) + }), + { numRuns: 50 }, + ) + }) }) - }) describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) + test("prompt appends immutably when no assistant is pending", () => { + FastCheck.assert( + FastCheck.property(word, (body) => { + const old = memoryState() + const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + expect(old).not.toBe(next) + expect(old.entries).toHaveLength(0) + expect(next.entries).toHaveLength(1) + }), + { numRuns: 50 }, + ) + }) - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) + test("prompt appends immutably when an assistant is pending", () => { + FastCheck.assert( + FastCheck.property(word, (body) => { + const old = active() + const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) + expect(old).not.toBe(next) + expect(old.pending).toHaveLength(0) + expect(next.pending).toHaveLength(1) + }), + { numRuns: 50 }, + ) + }) - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" + test("step.started creates an assistant consumed by follow-up events", () => { + FastCheck.assert( + FastCheck.property(texts, (parts) => { const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), SessionEvent.Step.Started.create({ model: { id: "model", @@ -596,26 +519,8 @@ describe("session-entry-stepper", () => { }, timestamp: time(1), }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), + SessionEvent.Text.Started.create({ timestamp: time(2) }), + ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), SessionEvent.Step.Ended.create({ reason: "stop", cost: 1, @@ -628,194 +533,318 @@ describe("session-entry-stepper", () => { write: 5, }, }, - timestamp: time(reason.length + 7), + timestamp: time(parts.length + 3), + }), + ]) + const entry = last(next) + + expect(entry).toBeDefined() + if (!entry) return + + expect(entry.content).toEqual([ + { + type: "text", + text: parts.join(""), + }, + ]) + expect(entry.time.completed).toEqual(time(parts.length + 3)) + }), + { numRuns: 100 }, + ) + }) + + test("replays prompt -> step -> text -> step.ended", () => { + FastCheck.assert( + FastCheck.property(word, texts, (body, parts) => { + const next = run([ + SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), + SessionEvent.Step.Started.create({ + model: { + id: "model", + providerID: "provider", + }, + timestamp: time(1), + }), + SessionEvent.Text.Started.create({ timestamp: time(2) }), + ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), + SessionEvent.Step.Ended.create({ + reason: "stop", + cost: 1, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { + read: 4, + write: 5, + }, + }, + timestamp: time(parts.length + 3), }), ]) - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return + expect(next.entries).toHaveLength(2) + expect(next.entries[0]?.type).toBe("user") + expect(next.entries[1]?.type).toBe("assistant") + if (next.entries[1]?.type !== "assistant") return - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), + expect(next.entries[1].content).toEqual([ + { + type: "text", + text: parts.join(""), + }, + ]) + expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return + { numRuns: 50 }, + ) + }) - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) + test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { + FastCheck.assert( + FastCheck.property( + word, + texts, + text, + dict, + word, + maybe(text), + maybe(dict), + maybe(files), + (body, reason, end, input, title, output, metadata, attachments) => { + const callID = "call" + const next = run([ + SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), + SessionEvent.Step.Started.create({ + model: { + id: "model", + providerID: "provider", + }, + timestamp: time(1), + }), + SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), + ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), + SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), + SessionEvent.Tool.Called.create({ + callID, + tool: "bash", + input, + provider: { executed: true }, + timestamp: time(reason.length + 5), + }), + SessionEvent.Tool.Success.create({ + callID, + title, + output, + metadata, + attachments, + provider: { executed: true }, + timestamp: time(reason.length + 6), + }), + SessionEvent.Step.Ended.create({ + reason: "stop", + cost: 1, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { + read: 4, + write: 5, + }, + }, + timestamp: time(reason.length + 7), + }), + ]) - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) + expect(next.entries.at(-1)?.type).toBe("assistant") + const entry = next.entries.at(-1) + if (entry?.type !== "assistant") return - const first = tool(next, "a") - const second = tool(next, "b") + expect(entry.content).toHaveLength(2) + expect(entry.content[0]).toEqual({ + type: "reasoning", + text: end, + }) + expect(entry.content[1]?.type).toBe("tool") + if (entry.content[1]?.type !== "tool") return + expect(entry.content[1].state.status).toBe("completed") + expect(entry.time.completed).toEqual(time(reason.length + 7)) + }, + ), + { numRuns: 50 }, + ) + }) - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) + test("starting a new step completes the old assistant and appends a new active assistant", () => { + const next = run( + [ + SessionEvent.Step.Started.create({ + model: { + id: "model", + providerID: "provider", + }, + timestamp: time(1), + }), + ], + active(), + ) + expect(next.entries).toHaveLength(2) + expect(next.entries[0]?.type).toBe("assistant") + expect(next.entries[1]?.type).toBe("assistant") + if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) + expect(next.entries[0].time.completed).toEqual(time(1)) + expect(next.entries[1].time.created).toEqual(time(1)) + expect(next.entries[1].time.completed).toBeUndefined() + }) - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) + test("handles sequential tools independently", () => { + FastCheck.assert( + FastCheck.property(dict, dict, word, word, (a, b, title, error) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Called.create({ + callID: "a", + tool: "bash", + input: a, + provider: { executed: true }, + timestamp: time(2), + }), + SessionEvent.Tool.Success.create({ + callID: "a", + title, + output: "done", + provider: { executed: true }, + timestamp: time(3), + }), + SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), + SessionEvent.Tool.Called.create({ + callID: "b", + tool: "bash", + input: b, + provider: { executed: true }, + timestamp: time(5), + }), + SessionEvent.Tool.Error.create({ + callID: "b", + error, + provider: { executed: true }, + timestamp: time(6), + }), + ], + active(), + ) - const first = tool(next, "a") - const second = tool(next, "b") + const first = tool(next, "a") + const second = tool(next, "b") - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return + expect(first?.state.status).toBe("completed") + if (first?.state.status !== "completed") return + expect(first.state.input).toEqual(a) + expect(first.state.output).toBe("done") + expect(first.state.title).toBe(title) - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) + expect(second?.state.status).toBe("error") + if (second?.state.status !== "error") return + expect(second.state.input).toEqual(b) + expect(second.state.error).toBe(error) + }), + { numRuns: 50 }, + ) + }) - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step(memoryState(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) })) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) + test("routes tool events by callID when tool streams interleave", () => { + FastCheck.assert( + FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), + SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), + SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), + SessionEvent.Tool.Called.create({ + callID: "a", + tool: "bash", + input: a, + provider: { executed: true }, + timestamp: time(5), + }), + SessionEvent.Tool.Called.create({ + callID: "b", + tool: "grep", + input: b, + provider: { executed: true }, + timestamp: time(6), + }), + SessionEvent.Tool.Success.create({ + callID: "a", + title: titleA, + output: "done-a", + provider: { executed: true }, + timestamp: time(7), + }), + SessionEvent.Tool.Success.create({ + callID: "b", + title: titleB, + output: "done-b", + provider: { executed: true }, + timestamp: time(8), + }), + ], + active(), + ) - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) + const first = tool(next, "a") + const second = tool(next, "b") + + expect(first?.state.status).toBe("completed") + expect(second?.state.status).toBe("completed") + if (first?.state.status !== "completed" || second?.state.status !== "completed") return + + expect(first.state.input).toEqual(a) + expect(second.state.input).toEqual(b) + expect(first.state.title).toBe(titleA) + expect(second.state.title).toBe(titleB) + }), + { numRuns: 50 }, + ) + }) + + test("records synthetic events", () => { + FastCheck.assert( + FastCheck.property(word, (body) => { + const next = SessionEntryStepper.step( + memoryState(), + SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), + ) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("synthetic") + if (next.entries[0]?.type !== "synthetic") return + expect(next.entries[0].text).toBe(body) + }), + { numRuns: 50 }, + ) + }) + + test("records compaction events", () => { + FastCheck.assert( + FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { + const next = SessionEntryStepper.step( + memoryState(), + SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), + ) + expect(next.entries).toHaveLength(1) + expect(next.entries[0]?.type).toBe("compaction") + if (next.entries[0]?.type !== "compaction") return + expect(next.entries[0].auto).toBe(auto) + expect(next.entries[0].overflow).toBe(overflow) + }), + { numRuns: 50 }, + ) + }) }) }) }) From 882b8e1e7587c4b24e5cb7ee9409e93b9455c5b0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 10:35:25 -0400 Subject: [PATCH 086/130] core: track retry attempts with detailed error context on assistant entries users can now see when transient failures occur during assistant responses, such as rate limits or provider overloads, giving visibility into what issues were encountered and automatically resolved before the final response --- .../opencode/src/v2/session-entry-stepper.ts | 12 ++- packages/opencode/src/v2/session-entry.ts | 20 +++++ packages/opencode/src/v2/session-event.ts | 15 +++- .../session/session-entry-stepper.test.ts | 87 +++++++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts index 3d642579d0d..3fe4266c04c 100644 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ b/packages/opencode/src/v2/session-entry-stepper.ts @@ -1,4 +1,4 @@ -import { castDraft, produce, type WritableDraft } from "immer" +import { produce, type WritableDraft } from "immer" import { SessionEvent } from "./session-event" import { SessionEntry } from "./session-entry" @@ -235,7 +235,15 @@ export function stepWith(adapter: Adapter, event: SessionEvent.E ) } }, - retried: () => {}, + retried: (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] + }), + ) + } + }, compacted: (event) => { adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) }, diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 97c5fc7ce91..b261d8b5b25 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -104,6 +104,24 @@ export class AssistantReasoning extends Schema.Class("Sessio text: Schema.String, }) {} +export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ + attempt: Schema.Number, + error: SessionEvent.RetryError, + time: Schema.Struct({ + created: Schema.DateTimeUtc, + }), +}) { + static fromEvent(event: SessionEvent.Retried) { + return new AssistantRetry({ + attempt: event.attempt, + error: event.error, + time: { + created: event.timestamp, + }, + }) + } +} + export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( Schema.toTaggedUnion("type"), ) @@ -113,6 +131,7 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" ...Base, type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), + retries: AssistantRetry.pipe(Schema.Array, Schema.optional), cost: Schema.Number.pipe(Schema.optional), tokens: Schema.Struct({ input: Schema.Number, @@ -137,6 +156,7 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" created: event.timestamp, }, content: [], + retries: [], }) } } diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 11d4a5db2d3..f922becf3af 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -53,6 +53,15 @@ export namespace SessionEvent { source: Source.pipe(Schema.optional), }) {} + export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ + message: Schema.String, + statusCode: Schema.Number.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + }) {} + export class Prompt extends Schema.Class("Session.Event.Prompt")({ ...Base, type: Schema.Literal("prompt"), @@ -386,14 +395,16 @@ export namespace SessionEvent { export class Retried extends Schema.Class("Session.Event.Retried")({ ...Base, type: Schema.Literal("retried"), - error: Schema.String, + attempt: Schema.Number, + error: RetryError, }) { - static create(input: BaseInput & { error: string }) { + static create(input: BaseInput & { attempt: number; error: RetryError }) { return new Retried({ id: input.id ?? ID.create(), type: "retried", timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), metadata: input.metadata, + attempt: input.attempt, error: input.error, }) } diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index 5c7df2dbadb..32036cb1e83 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -27,6 +27,24 @@ function assistant() { type: "assistant", time: { created: time(0) }, content: [], + retries: [], + }) +} + +function retryError(message: string) { + return new SessionEvent.RetryError({ + message, + isRetryable: true, + }) +} + +function retry(attempt: number, message: string, created: number) { + return new SessionEntry.AssistantRetry({ + attempt, + error: retryError(message), + time: { + created: time(created), + }, }) } @@ -78,6 +96,12 @@ function tool(state: SessionEntryStepper.MemoryState, callID: string) { return tools(state).find((x) => x.callID === callID) } +function retriesOf(state: SessionEntryStepper.MemoryState) { + const entry = last(state) + if (!entry) return [] + return entry.retries ?? [] +} + function adapterStore() { return { committed: [] as SessionEntry.Entry[], @@ -168,6 +192,33 @@ describe("session-entry-stepper", () => { ]) expect(store.committed[0].time.completed).toEqual(time(7)) }) + + test("aggregates retry events onto the current assistant", () => { + const store = adapterStore() + store.committed.push(assistant()) + + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Retried.create({ + attempt: 1, + error: retryError("rate limited"), + timestamp: time(1), + }), + ) + SessionEntryStepper.stepWith( + adapterFor(store), + SessionEvent.Retried.create({ + attempt: 2, + error: retryError("provider overloaded"), + timestamp: time(2), + }), + ) + + expect(store.committed[0]?.type).toBe("assistant") + if (store.committed[0]?.type !== "assistant") return + + expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) + }) }) describe("memory", () => { @@ -231,6 +282,21 @@ describe("session-entry-stepper", () => { expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) }) + + test("stepWith through memory records retries", () => { + const state = active() + + SessionEntryStepper.stepWith( + SessionEntryStepper.memory(state), + SessionEvent.Retried.create({ + attempt: 1, + error: retryError("rate limited"), + timestamp: time(1), + }), + ) + + expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) + }) }) describe("step", () => { @@ -481,6 +547,27 @@ describe("session-entry-stepper", () => { }) }) + test("records retries on the pending assistant", () => { + const next = run( + [ + SessionEvent.Retried.create({ + attempt: 1, + error: retryError("rate limited"), + timestamp: time(1), + }), + SessionEvent.Retried.create({ + attempt: 2, + error: retryError("provider overloaded"), + timestamp: time(2), + }), + ], + active(), + ) + + expect(retriesOf(next)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) + }) + }) + describe("known reducer gaps", () => { test("prompt appends immutably when no assistant is pending", () => { FastCheck.assert( From 55315bdffaae45d8a983b90836308ad460fc45e4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 10:37:33 -0400 Subject: [PATCH 087/130] tui: fix sync loading indicator to properly show loading state on startup --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 1 - .../session/session-entry-stepper.test.ts | 21 ------------------- 2 files changed, 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d2a7e5c4d08..57326e3a1ab 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -467,7 +467,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return store.status }, get ready() { - return true if (process.env.OPENCODE_FAST_BOOT) return true return store.status !== "loading" }, diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index 32036cb1e83..defce40c14f 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -547,27 +547,6 @@ describe("session-entry-stepper", () => { }) }) - test("records retries on the pending assistant", () => { - const next = run( - [ - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ], - active(), - ) - - expect(retriesOf(next)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - describe("known reducer gaps", () => { test("prompt appends immutably when no assistant is pending", () => { FastCheck.assert( From 1ee712e549f8e31e38d70abd600ed48010659f8a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 10:42:33 -0400 Subject: [PATCH 088/130] core: fix early return when node_modules is missing during package install --- packages/opencode/src/npm/index.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index d92099bc3cc..0760e768b98 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -150,13 +150,17 @@ export const layer = Layer.effect( if (!canWrite) return const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? [] - yield* Effect.gen(function* () { - const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) - if (!nodeModulesExists) { - yield* reify({ add, dir }) - return - } - }).pipe(Effect.withSpan("Npm.checkNodeModules")) + if ( + yield* Effect.gen(function* () { + const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) + if (!nodeModulesExists) { + yield* reify({ add, dir }) + return true + } + return false + }).pipe(Effect.withSpan("Npm.checkNodeModules")) + ) + return yield* Effect.gen(function* () { const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({}))) From 078d8a07cf848a7dd8067121d7c21c00d717b9d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 11:20:29 -0400 Subject: [PATCH 089/130] core: support OTEL_RESOURCE_ATTRIBUTES environment variable for custom telemetry attributes Users can now pass custom OpenTelemetry resource attributes via the OTEL_RESOURCE_ATTRIBUTES environment variable (comma-separated key=value format). These attributes are automatically included in all telemetry data sent from both the main process and workspace environments, enabling better observability integration with existing monitoring systems that rely on custom resource tags. --- .../opencode/src/control-plane/workspace.ts | 1 + packages/opencode/src/effect/observability.ts | 19 +++++++- .../test/effect/observability.test.ts | 45 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/effect/observability.test.ts diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e94d6c2c938..eb689df0257 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -117,6 +117,7 @@ export const create = fn(CreateInput, async (input) => { OPENCODE_EXPERIMENTAL_WORKSPACES: "true", OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } await adaptor.create(config, env) diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index 1c385d60ae2..fd719fd353a 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -21,12 +21,29 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ) : undefined -function resource() { +export function resource(): { serviceName: string, serviceVersion: string, attributes: Record } { const processMetadata = ensureProcessMetadata("main") + const attributes: Record = (() => { + const value = process.env.OTEL_RESOURCE_ATTRIBUTES + if (!value) return {} + try { + return Object.fromEntries( + value.split(",").map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") + return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] + }), + ) + } catch { + return {} + } + })() + return { serviceName: "opencode", serviceVersion: InstallationVersion, attributes: { + ...attributes, "deployment.environment.name": InstallationChannel, "opencode.client": Flag.OPENCODE_CLIENT, "opencode.process_role": processMetadata.processRole, diff --git a/packages/opencode/test/effect/observability.test.ts b/packages/opencode/test/effect/observability.test.ts new file mode 100644 index 00000000000..dd380a2de70 --- /dev/null +++ b/packages/opencode/test/effect/observability.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { resource } from "../../src/effect/observability" + +const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES +const opencodeClient = process.env.OPENCODE_CLIENT + +afterEach(() => { + if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES + else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes + + if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT + else process.env.OPENCODE_CLIENT = opencodeClient +}) + +describe("resource", () => { + test("parses and decodes OTEL resource attributes", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = + "service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here" + + expect(resource().attributes).toMatchObject({ + "service.namespace": "anomalyco", + team: "platform,observability", + label: "hello=world", + "key/name": "value here", + }) + }) + + test("drops OTEL resource attributes when any entry is invalid", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken" + + expect(resource().attributes["service.namespace"]).toBeUndefined() + expect(resource().attributes["opencode.client"]).toBeDefined() + }) + + test("keeps built-in attributes when env values conflict", () => { + process.env.OPENCODE_CLIENT = "cli" + process.env.OTEL_RESOURCE_ATTRIBUTES = "opencode.client=web,service.instance.id=override,service.namespace=anomalyco" + + expect(resource().attributes).toMatchObject({ + "opencode.client": "cli", + "service.namespace": "anomalyco", + }) + expect(resource().attributes["service.instance.id"]).not.toBe("override") + }) +}) From dd8c424806dd2c78c35205566824017ffa3a37fa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 15:21:48 +0000 Subject: [PATCH 090/130] chore: generate --- packages/opencode/src/effect/observability.ts | 2 +- packages/opencode/test/effect/observability.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index fd719fd353a..fb81d5f5b58 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -21,7 +21,7 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ) : undefined -export function resource(): { serviceName: string, serviceVersion: string, attributes: Record } { +export function resource(): { serviceName: string; serviceVersion: string; attributes: Record } { const processMetadata = ensureProcessMetadata("main") const attributes: Record = (() => { const value = process.env.OTEL_RESOURCE_ATTRIBUTES diff --git a/packages/opencode/test/effect/observability.test.ts b/packages/opencode/test/effect/observability.test.ts index dd380a2de70..d0622028274 100644 --- a/packages/opencode/test/effect/observability.test.ts +++ b/packages/opencode/test/effect/observability.test.ts @@ -34,7 +34,8 @@ describe("resource", () => { test("keeps built-in attributes when env values conflict", () => { process.env.OPENCODE_CLIENT = "cli" - process.env.OTEL_RESOURCE_ATTRIBUTES = "opencode.client=web,service.instance.id=override,service.namespace=anomalyco" + process.env.OTEL_RESOURCE_ATTRIBUTES = + "opencode.client=web,service.instance.id=override,service.namespace=anomalyco" expect(resource().attributes).toMatchObject({ "opencode.client": "cli", From 9918f389e7ffe0b5b6e6327045ecd6559775f7c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 11:59:08 -0400 Subject: [PATCH 091/130] fix: detect attachment mime from file contents (#23291) --- packages/opencode/src/session/message-v2.ts | 6 +- packages/opencode/src/tool/read.ts | 137 +++++++++--------- packages/opencode/src/tool/webfetch.ts | 6 +- packages/opencode/src/util/media.ts | 29 ++++ .../opencode/test/session/message-v2.test.ts | 84 +++++++++++ packages/opencode/test/tool/read.test.ts | 13 ++ 6 files changed, 198 insertions(+), 77 deletions(-) create mode 100644 packages/opencode/src/util/media.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 46686947e1f..057b5eb66ab 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" +import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -25,10 +26,7 @@ interface FetchDecompressionError extends Error { } export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" - -export function isMedia(mime: string) { - return mime.startsWith("image/") || mime === "application/pdf" -} +export { isMedia } export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 18c668ca070..29d36692c66 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,7 +1,6 @@ import z from "zod" -import { Effect, Scope } from "effect" +import { Effect, Option, Scope } from "effect" import { createReadStream } from "fs" -import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" import * as Tool from "./tool" @@ -11,12 +10,14 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` +const SAMPLE_BYTES = 4096 const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), @@ -77,6 +78,64 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) + const readSample = Effect.fn("ReadTool.readSample")(function* (filepath: string, fileSize: number, sampleSize: number) { + if (fileSize === 0) return new Uint8Array() + + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(filepath, { flag: "r" }) + return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array()) + }), + ) + }) + + const isBinaryFile = (filepath: string, bytes: Uint8Array) => { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + } + + if (bytes.length === 0) return false + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + + return nonPrintableCount / bytes.length > 0.3 + } + const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) @@ -141,12 +200,12 @@ export const ReadTool = Tool.define( } const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) + const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) - const mime = AppFileSystem.mimeType(filepath) - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - const isPdf = mime === "application/pdf" - if (isImage || isPdf) { - const msg = `${isImage ? "Image" : "PDF"} read successfully` + const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) + if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const bytes = yield* fs.readFile(filepath) + const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { title, output: msg, @@ -159,13 +218,13 @@ export const ReadTool = Tool.define( { type: "file" as const, mime, - url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`, + url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, }, ], } } - if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) { + if (isBinaryFile(filepath, sample)) { return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`)) } @@ -261,63 +320,3 @@ async function lines(filepath: string, opts: { limit: number; offset: number }) return { raw, count, cut, more, offset: opts.offset } } - -async function isBinaryFile(filepath: string, fileSize: number): Promise { - const ext = path.extname(filepath).toLowerCase() - // binary check for common non-text extensions - switch (ext) { - case ".zip": - case ".tar": - case ".gz": - case ".exe": - case ".dll": - case ".so": - case ".class": - case ".jar": - case ".war": - case ".7z": - case ".doc": - case ".docx": - case ".xls": - case ".xlsx": - case ".ppt": - case ".pptx": - case ".odt": - case ".ods": - case ".odp": - case ".bin": - case ".dat": - case ".obj": - case ".o": - case ".a": - case ".lib": - case ".wasm": - case ".pyc": - case ".pyo": - return true - default: - break - } - - if (fileSize === 0) return false - - const fh = await open(filepath, "r") - try { - const sampleSize = Math.min(4096, fileSize) - const bytes = Buffer.alloc(sampleSize) - const result = await fh.read(bytes, 0, sampleSize, 0) - if (result.bytesRead === 0) return false - - let nonPrintableCount = 0 - for (let i = 0; i < result.bytesRead; i++) { - if (bytes[i] === 0) return true - if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { - nonPrintableCount++ - } - } - // If >30% non-printable characters, consider it binary - return nonPrintableCount / result.bytesRead > 0.3 - } finally { - await fh.close() - } -} diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 6498b871f83..1d988b8d4f2 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" +import { isImageAttachment } from "@/util/media" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -104,10 +105,7 @@ export const WebFetchTool = Tool.define( const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" const title = `${params.url} (${contentType})` - // Check if response is an image - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - - if (isImage) { + if (isImageAttachment(mime)) { const base64Content = Buffer.from(arrayBuffer).toString("base64") return { title, diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts new file mode 100644 index 00000000000..0e98f53a529 --- /dev/null +++ b/packages/opencode/src/util/media.ts @@ -0,0 +1,29 @@ +const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) + +export function isPdfAttachment(mime: string) { + return mime === "application/pdf" +} + +export function isMedia(mime: string) { + return mime.startsWith("image/") || isPdfAttachment(mime) +} + +export function isImageAttachment(mime: string) { + return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" +} + +export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if ( + startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && + startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]) + ) { + return "image/webp" + } + + return fallback +} diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 6d4e994a879..55ae65c5602 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" +import { ProviderTransform } from "../../src/provider" import type { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" @@ -359,6 +360,89 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("preserves jpeg tool-result media for anthropic models", async () => { + const anthropicModel: Provider.Model = { + ...model, + id: ModelID.make("anthropic/claude-opus-4-7"), + providerID: ProviderID.make("anthropic"), + api: { + id: "claude-opus-4-7-20250805", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString( + "base64", + ) + const userID = "m-user-anthropic" + const assistantID = "m-assistant-anthropic" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-anthropic"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-anthropic"), + type: "tool", + callID: "call-anthropic-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/rails-demo.png" }, + output: "Image read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-anthropic-1"), + type: "file", + mime: "image/jpeg", + filename: "rails-demo.png", + url: `data:image/jpeg;base64,${jpeg}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {}) + expect(result).toHaveLength(3) + expect(result[2].role).toBe("tool") + expect(result[2].content[0]).toMatchObject({ + type: "tool-result", + toolCallId: "call-anthropic-1", + toolName: "read", + output: { + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "media", mediaType: "image/jpeg", data: jpeg }, + ], + }, + }) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7456990ad0e..42817d15dfa 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -394,6 +394,19 @@ describe("tool.read truncation", () => { }), ) + it.live("detects attachment media from file contents", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) + yield* put(path.join(dir, "image.bin"), jpeg) + + const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") }) + expect(result.output).toBe("Image read successfully") + expect(result.attachments?.[0].mime).toBe("image/jpeg") + expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true) + }), + ) + it.live("large image files are properly attached without error", () => Effect.gen(function* () { const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") }) From c5c38cad9c444070fa3b18d569fa75eb2ab40407 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 16:00:01 +0000 Subject: [PATCH 092/130] chore: generate --- packages/opencode/src/tool/read.ts | 6 +++++- packages/opencode/src/util/media.ts | 5 +---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 29d36692c66..c9b30486265 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -78,7 +78,11 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) - const readSample = Effect.fn("ReadTool.readSample")(function* (filepath: string, fileSize: number, sampleSize: number) { + const readSample = Effect.fn("ReadTool.readSample")(function* ( + filepath: string, + fileSize: number, + sampleSize: number, + ) { if (fileSize === 0) return new Uint8Array() return yield* Effect.scoped( diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts index 0e98f53a529..566ac843a63 100644 --- a/packages/opencode/src/util/media.ts +++ b/packages/opencode/src/util/media.ts @@ -18,10 +18,7 @@ export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) { if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" - if ( - startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && - startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]) - ) { + if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) { return "image/webp" } From 5eaef6b758cbfb683deadb8d440a420c3c1ee1f8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 12:31:59 -0400 Subject: [PATCH 093/130] release: avoid package.json drift during publish --- packages/plugin/script/publish.ts | 36 ++++++++++++++++++------------- packages/sdk/js/script/publish.ts | 19 +++++++++------- script/publish.ts | 17 +++++++++++++++ 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index de129918cd5..fea8c7230cf 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -11,22 +11,28 @@ async function published(name: string, version: string) { } await $`bun tsc` -const pkg = await import("../package.json").then( - (m) => m.default as { name: string; version: string; exports: Record }, -) -const original = JSON.parse(JSON.stringify(pkg)) +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} if (await published(pkg.name, pkg.version)) { console.log(`already published ${pkg.name}@${pkg.version}`) - process.exit(0) -} -for (const [key, value] of Object.entries(pkg.exports)) { - const file = value.replace("./src/", "./dist/").replace(".ts", "") - // @ts-ignore - pkg.exports[key] = { - import: file + ".js", - types: file + ".d.ts", +} else { + for (const [key, value] of Object.entries(pkg.exports)) { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + // @ts-ignore + pkg.exports[key] = { + import: file + ".js", + types: file + ".d.ts", + } + } + await Bun.write("package.json", JSON.stringify(pkg, null, 2)) + try { + await $`bun pm pack` + await $`npm publish *.tgz --tag ${Script.channel} --access public` + } finally { + await Bun.write("package.json", originalText) } } -await Bun.write("package.json", JSON.stringify(pkg, null, 2)) -await $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public` -await Bun.write("package.json", JSON.stringify(original, null, 2)) diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index b5e1211fc43..29426a41b7d 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -11,12 +11,12 @@ async function published(name: string, version: string) { return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 } -const pkg = (await import("../package.json").then((m) => m.default)) as { +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { name: string version: string exports: Record } -const original = JSON.parse(JSON.stringify(pkg)) function transformExports(exports: Record) { return Object.fromEntries( Object.entries(exports).map(([key, value]) => { @@ -33,10 +33,13 @@ function transformExports(exports: Record) { } if (await published(pkg.name, pkg.version)) { console.log(`already published ${pkg.name}@${pkg.version}`) - process.exit(0) +} else { + pkg.exports = transformExports(pkg.exports) + await Bun.write("package.json", JSON.stringify(pkg, null, 2)) + try { + await $`bun pm pack` + await $`npm publish *.tgz --tag ${Script.channel} --access public` + } finally { + await Bun.write("package.json", originalText) + } } -pkg.exports = transformExports(pkg.exports) -await Bun.write("package.json", JSON.stringify(pkg, null, 2)) -await $`bun pm pack` -await $`npm publish *.tgz --tag ${Script.channel} --access public` -await Bun.write("package.json", JSON.stringify(original, null, 2)) diff --git a/script/publish.ts b/script/publish.ts index 6cd244e0e6a..c8e39a4b3d5 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -15,11 +15,23 @@ const pkgjsons = await Array.fromAsync( ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) +const publishPackageJsons = ["packages/plugin/package.json", "packages/sdk/js/package.json"] async function hasChanges() { return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 } +async function hasPublishPackageJsonChanges() { + if ((await $`git diff --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0) return true + return (await $`git diff --cached --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0 +} + +async function logPublishPackageJsonChanges() { + await $`git status --short -- ${publishPackageJsons}` + await $`git diff -- ${publishPackageJsons}` + await $`git diff --cached -- ${publishPackageJsons}` +} + async function releaseTagExists() { return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0 } @@ -76,6 +88,11 @@ if (Script.release) { if (Script.release && !Script.preview) { await $`git fetch origin` + if (await hasPublishPackageJsonChanges()) { + console.error("publish scripts left package.json changes before syncing dev") + await logPublishPackageJsonChanges() + throw new Error("packages/plugin/package.json or packages/sdk/js/package.json changed during publish") + } await $`git checkout -B dev origin/dev` await prepareReleaseFiles() if (await hasChanges()) { From a26d53151b11151a5580f11790b768ac334fa6a8 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 18 Apr 2026 16:20:23 -0400 Subject: [PATCH 094/130] tui: allow full-session forks from the session dialog (#23339) --- .../session/dialog-fork-from-timeline.tsx | 25 +++++++++++++------ .../src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 8d1e4438c84..7414cefd3db 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" -import { useDialog } from "../../ui/dialog" +import { useDialog, type DialogContext } from "../../ui/dialog" import type { PromptInfo } from "@tui/component/prompt/history" import { strip } from "@tui/component/prompt/part" -export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) { +export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { const sync = useSync() const dialog = useDialog() const sdk = useSDK() @@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess dialog.setSize("large") }) - const options = createMemo((): DialogSelectOption[] => { + const options = createMemo((): DialogSelectOption[] => { const messages = sync.data.message[props.sessionID] ?? [] - const result = [] as DialogSelectOption[] + const fullSession = { + title: "Full session", + value: undefined, + onSelect: async (dialog: DialogContext) => { + const forked = await sdk.client.session.fork({ sessionID: props.sessionID }) + route.navigate({ + sessionID: forked.data!.id, + type: "session", + }) + dialog.clear() + }, + } satisfies DialogSelectOption + const result = [] as DialogSelectOption[] for (const message of messages) { if (message.role !== "user") continue const part = (sync.data.part[message.id] ?? []).find( @@ -57,9 +69,8 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess }, }) } - result.reverse() - return result + return [fullSession, ...result.reverse()] }) - return props.onMove(option.value)} title="Fork from message" options={options()} /> + return props.onMove(option.value)} title="Fork session" options={options()} /> } 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 ccca4d1eba6..06be5dfbefb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -451,7 +451,7 @@ export function Session() { }, }, { - title: "Fork from message", + title: "Fork session", value: "session.fork", keybind: "session_fork", category: "Session", @@ -462,6 +462,7 @@ export function Session() { dialog.replace(() => ( { + if (!messageID) return const child = scroll.getChildren().find((child) => { return child.id === messageID }) From ce7923adafd03e9cea88f42b68a9adc6fece7156 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:00:46 -0500 Subject: [PATCH 095/130] chore: bump @ai-sdk/amazon-bedrock (#23341) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 3cb3cbea602..947e355d7f6 100644 --- a/bun.lock +++ b/bun.lock @@ -322,7 +322,7 @@ "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.95", + "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", @@ -740,7 +740,7 @@ "@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.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="], "@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=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2acbc4fe84c..40f25f8f7b6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -79,7 +79,7 @@ "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.95", + "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", From e2e7a8d722cf4b0a387717e9eee6d2f8fd4a81d4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:04:00 -0500 Subject: [PATCH 096/130] fix: ensure display: summarized is sent by default for bedrock (#23343) --- packages/opencode/src/provider/transform.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1b6b0918b1e..4ed43ce9943 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -635,6 +635,9 @@ export function variants(model: Provider.Model): Record Date: Sat, 18 Apr 2026 21:32:47 +0000 Subject: [PATCH 097/130] 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 01366c82dc4..34b90955625 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=", - "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=", - "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=", - "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA=" + "x86_64-linux": "sha256-Spc0qzg4he0d0GwHcqj7uBmvK4DIF1tEbsZ6M+pxpWc=", + "aarch64-linux": "sha256-gwz/PKBbT+72hr7vUG28cdx4Z7/Sf06PNMr9JBjAYg0=", + "aarch64-darwin": "sha256-Lj8p9P/QzLqxiM1OVSwcbtTsms8AcW3A6H0575ERufw=", + "x86_64-darwin": "sha256-y0e+TnXj6wKDqSC5hQAWjpKadaFvL6GJ6Mba5anBM+Y=" } } From 9d012b062186ef9900cc2673b77c446d38ebd789 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 18 Apr 2026 16:26:58 -0400 Subject: [PATCH 098/130] zen: redeem credit --- packages/console/app/src/i18n/ar.ts | 7 + packages/console/app/src/i18n/br.ts | 7 + packages/console/app/src/i18n/da.ts | 7 + packages/console/app/src/i18n/de.ts | 7 + packages/console/app/src/i18n/en.ts | 7 + packages/console/app/src/i18n/es.ts | 7 + packages/console/app/src/i18n/fr.ts | 7 + packages/console/app/src/i18n/it.ts | 7 + packages/console/app/src/i18n/ja.ts | 7 + packages/console/app/src/i18n/ko.ts | 7 + packages/console/app/src/i18n/no.ts | 7 + packages/console/app/src/i18n/pl.ts | 7 + packages/console/app/src/i18n/ru.ts | 7 + packages/console/app/src/i18n/th.ts | 7 + packages/console/app/src/i18n/tr.ts | 7 + packages/console/app/src/i18n/zh.ts | 7 + packages/console/app/src/i18n/zht.ts | 7 + .../routes/workspace/[id]/billing/index.tsx | 2 + .../[id]/billing/redeem-section.module.css | 61 + .../workspace/[id]/billing/redeem-section.tsx | 71 + .../migration.sql | 6 + .../snapshot.json | 2670 +++++++++++++++++ packages/console/core/script/create-coupon.ts | 24 + packages/console/core/src/billing.ts | 33 +- .../console/core/src/schema/billing.sql.ts | 24 +- 25 files changed, 3007 insertions(+), 3 deletions(-) create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx create mode 100644 packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql create mode 100644 packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json create mode 100644 packages/console/core/script/create-coupon.ts diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 11f76ca598e..713703b2563 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -558,6 +558,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ", "workspace.monthlyLimit.currentUsage.beforeAmount": "هو $", + "workspace.redeem.title": "استرداد قسيمة", + "workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.", + "workspace.redeem.placeholder": "أدخل رمز القسيمة", + "workspace.redeem.redeem": "استرداد", + "workspace.redeem.redeeming": "جارٍ الاسترداد...", + "workspace.redeem.success": "تم استرداد القسيمة بنجاح.", + "workspace.reload.title": "إعادة الشحن التلقائي", "workspace.reload.disabled.before": "إعادة الشحن التلقائي", "workspace.reload.disabled.state": "معطّل", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 79f64485a11..bace965696d 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -567,6 +567,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para", "workspace.monthlyLimit.currentUsage.beforeAmount": "é $", + "workspace.redeem.title": "Resgatar Cupom", + "workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.", + "workspace.redeem.placeholder": "Digite o código do cupom", + "workspace.redeem.redeem": "Resgatar", + "workspace.redeem.redeeming": "Resgatando...", + "workspace.redeem.success": "Cupom resgatado com sucesso.", + "workspace.reload.title": "Recarga Automática", "workspace.reload.disabled.before": "A recarga automática está", "workspace.reload.disabled.state": "desativada", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 97b148c066a..c2d3bf3ba0e 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -563,6 +563,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for", "workspace.monthlyLimit.currentUsage.beforeAmount": "er $", + "workspace.redeem.title": "Indløs kupon", + "workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.", + "workspace.redeem.placeholder": "Indtast kuponkode", + "workspace.redeem.redeem": "Indløs", + "workspace.redeem.redeeming": "Indløser...", + "workspace.redeem.success": "Kuponen blev indløst.", + "workspace.reload.title": "Automatisk genopfyldning", "workspace.reload.disabled.before": "Automatisk genopfyldning er", "workspace.reload.disabled.state": "deaktiveret", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 1c5f631aea5..e44335bab9f 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -566,6 +566,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für", "workspace.monthlyLimit.currentUsage.beforeAmount": "ist $", + "workspace.redeem.title": "Gutschein einlösen", + "workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.", + "workspace.redeem.placeholder": "Gutscheincode eingeben", + "workspace.redeem.redeem": "Einlösen", + "workspace.redeem.redeeming": "Wird eingelöst...", + "workspace.redeem.success": "Gutschein erfolgreich eingelöst.", + "workspace.reload.title": "Auto-Reload", "workspace.reload.disabled.before": "Auto-Reload ist", "workspace.reload.disabled.state": "deaktiviert", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index d21d0dc9e0d..a9c3ca3cc59 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -559,6 +559,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for", "workspace.monthlyLimit.currentUsage.beforeAmount": "is $", + "workspace.redeem.title": "Redeem Coupon", + "workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.", + "workspace.redeem.placeholder": "Enter coupon code", + "workspace.redeem.redeem": "Redeem", + "workspace.redeem.redeeming": "Redeeming...", + "workspace.redeem.success": "Coupon redeemed successfully.", + "workspace.reload.title": "Auto Reload", "workspace.reload.disabled.before": "Auto reload is", "workspace.reload.disabled.state": "disabled", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index d6449fd954b..e2a0c271a54 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -567,6 +567,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para", "workspace.monthlyLimit.currentUsage.beforeAmount": "es $", + "workspace.redeem.title": "Canjear cupón", + "workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.", + "workspace.redeem.placeholder": "Introduce el código del cupón", + "workspace.redeem.redeem": "Canjear", + "workspace.redeem.redeeming": "Canjeando...", + "workspace.redeem.success": "Cupón canjeado correctamente.", + "workspace.reload.title": "Auto Recarga", "workspace.reload.disabled.before": "La auto recarga está", "workspace.reload.disabled.state": "deshabilitada", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index c54c431040f..5a353e8735d 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -569,6 +569,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour", "workspace.monthlyLimit.currentUsage.beforeAmount": "est de", + "workspace.redeem.title": "Utiliser un coupon", + "workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.", + "workspace.redeem.placeholder": "Saisissez le code promo", + "workspace.redeem.redeem": "Utiliser", + "workspace.redeem.redeeming": "Utilisation...", + "workspace.redeem.success": "Coupon utilisé avec succès.", + "workspace.reload.title": "Rechargement automatique", "workspace.reload.disabled.before": "Le rechargement automatique est", "workspace.reload.disabled.state": "désactivé", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index aadea6fec09..b19bff97889 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -565,6 +565,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per", "workspace.monthlyLimit.currentUsage.beforeAmount": "è $", + "workspace.redeem.title": "Riscatta Coupon", + "workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.", + "workspace.redeem.placeholder": "Inserisci il codice coupon", + "workspace.redeem.redeem": "Riscatta", + "workspace.redeem.redeeming": "Riscatto in corso...", + "workspace.redeem.success": "Coupon riscattato con successo.", + "workspace.reload.title": "Ricarica Auto", "workspace.reload.disabled.before": "La ricarica auto è", "workspace.reload.disabled.state": "disabilitata", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index f3b4c083e9f..1571345e5d6 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -564,6 +564,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(", "workspace.monthlyLimit.currentUsage.beforeAmount": ")は $", + "workspace.redeem.title": "クーポンを利用", + "workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。", + "workspace.redeem.placeholder": "クーポンコードを入力", + "workspace.redeem.redeem": "利用する", + "workspace.redeem.redeeming": "利用中...", + "workspace.redeem.success": "クーポンを利用しました。", + "workspace.reload.title": "自動チャージ", "workspace.reload.disabled.before": "自動チャージは", "workspace.reload.disabled.state": "無効", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index e2320c359fc..9ec9310314c 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -558,6 +558,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "현재", "workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $", + "workspace.redeem.title": "쿠폰 사용", + "workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.", + "workspace.redeem.placeholder": "쿠폰 코드를 입력하세요", + "workspace.redeem.redeem": "사용", + "workspace.redeem.redeeming": "사용 중...", + "workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.", + "workspace.reload.title": "자동 충전", "workspace.reload.disabled.before": "자동 충전이", "workspace.reload.disabled.state": "비활성화", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 717c2ad1ecf..132b85a6e28 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -564,6 +564,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for", "workspace.monthlyLimit.currentUsage.beforeAmount": "er $", + "workspace.redeem.title": "Løs inn kupong", + "workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.", + "workspace.redeem.placeholder": "Skriv inn kupongkode", + "workspace.redeem.redeem": "Løs inn", + "workspace.redeem.redeeming": "Løser inn...", + "workspace.redeem.success": "Kupongen ble løst inn.", + "workspace.reload.title": "Auto-påfyll", "workspace.reload.disabled.before": "Auto-påfyll er", "workspace.reload.disabled.state": "deaktivert", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index c9daeedee30..441dfc7bea2 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -565,6 +565,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za", "workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $", + "workspace.redeem.title": "Zrealizuj kupon", + "workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.", + "workspace.redeem.placeholder": "Wpisz kod kuponu", + "workspace.redeem.redeem": "Zrealizuj", + "workspace.redeem.redeeming": "Realizowanie...", + "workspace.redeem.success": "Kupon został zrealizowany.", + "workspace.reload.title": "Automatyczne doładowanie", "workspace.reload.disabled.before": "Automatyczne doładowanie jest", "workspace.reload.disabled.state": "wyłączone", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 01baa898528..ef7bcacd8bf 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -571,6 +571,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за", "workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $", + "workspace.redeem.title": "Активировать купон", + "workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.", + "workspace.redeem.placeholder": "Введите код купона", + "workspace.redeem.redeem": "Активировать", + "workspace.redeem.redeeming": "Активация...", + "workspace.redeem.success": "Купон успешно активирован.", + "workspace.reload.title": "Автопополнение", "workspace.reload.disabled.before": "Автопополнение", "workspace.reload.disabled.state": "отключено", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 59c90ef65db..d7d862d9489 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -560,6 +560,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ", "workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $", + "workspace.redeem.title": "แลกคูปอง", + "workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ", + "workspace.redeem.placeholder": "กรอกรหัสคูปอง", + "workspace.redeem.redeem": "แลก", + "workspace.redeem.redeeming": "กำลังแลก...", + "workspace.redeem.success": "แลกคูปองสำเร็จ", + "workspace.reload.title": "โหลดซ้ำอัตโนมัติ", "workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ", "workspace.reload.disabled.state": "ปิดใช้งานอยู่", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 196bf9d376d..13a074642dd 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -567,6 +567,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım", "workspace.monthlyLimit.currentUsage.beforeAmount": "$", + "workspace.redeem.title": "Kupon Kullan", + "workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.", + "workspace.redeem.placeholder": "Kupon kodunu girin", + "workspace.redeem.redeem": "Kullan", + "workspace.redeem.redeeming": "Kullanılıyor...", + "workspace.redeem.success": "Kupon başarıyla kullanıldı.", + "workspace.reload.title": "Otomatik Yeniden Yükleme", "workspace.reload.disabled.before": "Otomatik yeniden yükleme:", "workspace.reload.disabled.state": "devre dışı", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index aaec74fbaa0..c84ea5cc6bb 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -542,6 +542,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "当前", "workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $", + "workspace.redeem.title": "兑换优惠券", + "workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。", + "workspace.redeem.placeholder": "输入优惠码", + "workspace.redeem.redeem": "兑换", + "workspace.redeem.redeeming": "兑换中...", + "workspace.redeem.success": "优惠券兑换成功。", + "workspace.reload.title": "自动充值", "workspace.reload.disabled.before": "自动充值已", "workspace.reload.disabled.state": "禁用", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index b3e0fb07788..6a70a81c718 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -542,6 +542,13 @@ export const dict = { "workspace.monthlyLimit.currentUsage.beforeMonth": "目前", "workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $", + "workspace.redeem.title": "兌換優惠券", + "workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。", + "workspace.redeem.placeholder": "輸入優惠碼", + "workspace.redeem.redeem": "兌換", + "workspace.redeem.redeeming": "兌換中...", + "workspace.redeem.success": "優惠券兌換成功。", + "workspace.reload.title": "自動儲值", "workspace.reload.disabled.before": "自動儲值已", "workspace.reload.disabled.state": "停用", diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx index 4a7dc24888f..fb48485354b 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section" import { ReloadSection } from "./reload-section" import { PaymentSection } from "./payment-section" import { BlackSection } from "./black-section" +import { RedeemSection } from "./redeem-section" import { createMemo, Show } from "solid-js" import { createAsync, useParams } from "@solidjs/router" import { queryBillingInfo, querySessionInfo } from "../../common" @@ -26,6 +27,7 @@ export default function () { + diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css new file mode 100644 index 00000000000..42140e4e847 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css @@ -0,0 +1,61 @@ +.root { + [data-slot="redeem-container"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 20rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + } + + [data-slot="redeem-form"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + + [data-slot="input-row"] { + display: flex; + gap: var(--space-2); + align-items: stretch; + + @media (max-width: 30rem) { + flex-direction: column; + } + } + + input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + + [data-slot="form-success"] { + color: var(--color-success, var(--color-accent)); + font-size: var(--font-size-sm); + line-height: 1.4; + } + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx new file mode 100644 index 00000000000..872e8954bef --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx @@ -0,0 +1,71 @@ +import { json, action, useParams, useSubmission } from "@solidjs/router" +import { Show } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { Billing } from "@opencode-ai/console-core/billing.js" +import { User } from "@opencode-ai/console-core/user.js" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js" +import styles from "./redeem-section.module.css" +import { queryBillingInfo } from "../../common" +import { useI18n } from "~/context/i18n" +import { formError, localizeError } from "~/lib/form-error" + +const redeem = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID") as string | null + if (!workspaceID) return { error: formError.workspaceRequired } + const code = (form.get("code") as string | null)?.trim().toUpperCase() + if (!code) return { error: "Coupon code is required." } + if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." } + + return json( + await withActor(async () => { + const actor = Actor.assert("user") + const email = await User.getAuthEmail(actor.properties.userID) + if (!email) return { error: "No email on account." } + return Billing.redeemCoupon(email, code as (typeof CouponType)[number]) + .then(() => ({ error: undefined, data: true })) + .catch((e) => ({ error: e.message as string })) + }, workspaceID), + { revalidate: queryBillingInfo.key }, + ) +}, "billing.redeemCoupon") + +export function RedeemSection() { + const params = useParams() + const i18n = useI18n() + const submission = useSubmission(redeem) + + return ( +
+
+

{i18n.t("workspace.redeem.title")}

+

{i18n.t("workspace.redeem.subtitle")}

+
+
+
+
+ + +
+ + {(err: any) =>
{localizeError(i18n.t, err())}
} +
+ +
{i18n.t("workspace.redeem.success")}
+
+ +
+
+
+ ) +} diff --git a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql new file mode 100644 index 00000000000..7b7f7a2ebc7 --- /dev/null +++ b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE `coupon` ( + `email` varchar(255), + `type` enum('BUILDATHON','GOFREEMONTH') NOT NULL, + `time_redeemed` timestamp(3), + CONSTRAINT PRIMARY KEY(`email`,`type`) +); diff --git a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json new file mode 100644 index 00000000000..40e9d5e1527 --- /dev/null +++ b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json @@ -0,0 +1,2670 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "18b4281c-1609-47d8-9d51-0b08e3925f2b", + "prevIds": [ + "93c492af-c95b-4213-9fc2-38c3dd10374d" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": [ + "email", + "type" + ], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + "ip", + "interval" + ], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "ip" + ], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + "key", + "interval" + ], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "key", + "interval" + ], + "name": "PRIMARY", + "table": "model_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/console/core/script/create-coupon.ts b/packages/console/core/script/create-coupon.ts new file mode 100644 index 00000000000..e1333790589 --- /dev/null +++ b/packages/console/core/script/create-coupon.ts @@ -0,0 +1,24 @@ +import { Database } from "../src/drizzle/index.js" +import { CouponTable, CouponType } from "../src/schema/billing.sql.js" + +const email = process.argv[2] +const type = process.argv[3] as (typeof CouponType)[number] + +if (!email || !type) { + console.error(`Usage: bun create-coupon.ts <${CouponType.join("|")}>`) + process.exit(1) +} + +if (!(CouponType as readonly string[]).includes(type)) { + console.error(`Error: type must be one of ${CouponType.join(", ")}`) + process.exit(1) +} + +await Database.use((tx) => + tx.insert(CouponTable).values({ + email, + type, + }), +) + +console.log(`Created ${type} coupon for ${email}`) diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 9de413e60b5..d96b19ad79d 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -1,6 +1,14 @@ import { Stripe } from "stripe" -import { Database, eq, sql } from "./drizzle" -import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql" +import { and, Database, eq, sql } from "./drizzle" +import { + BillingTable, + CouponTable, + CouponType, + LiteTable, + PaymentTable, + SubscriptionTable, + UsageTable, +} from "./schema/billing.sql" import { Actor } from "./actor" import { fn } from "./util/fn" import { z } from "zod" @@ -147,6 +155,27 @@ export namespace Billing { return amountInMicroCents } + export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => { + const coupon = await Database.use((tx) => + tx + .select() + .from(CouponTable) + .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))) + .then((rows) => rows[0]), + ) + if (!coupon) throw new Error("Invalid coupon code") + if (coupon.timeRedeemed) throw new Error("Coupon already redeemed") + + if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500) + + await Database.use((tx) => + tx + .update(CouponTable) + .set({ timeRedeemed: sql`now()` }) + .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))), + ) + } + export const setMonthlyLimit = fn(z.number(), async (input) => { return await Database.use((tx) => tx diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index b06ca8966d0..f8dcbd2b1bf 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -1,4 +1,15 @@ -import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" +import { + bigint, + boolean, + index, + int, + json, + mysqlEnum, + mysqlTable, + primaryKey, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" @@ -121,3 +132,14 @@ export const UsageTable = mysqlTable( }, (table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)], ) + +export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const +export const CouponTable = mysqlTable( + "coupon", + { + email: varchar("email", { length: 255 }), + type: mysqlEnum("type", CouponType).notNull(), + timeRedeemed: utc("time_redeemed"), + }, + (table) => [primaryKey({ columns: [table.email, table.type] })], +) From 54b3b3fe05fd66f8f3b479ec438b53d38dbc6d0d Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 18 Apr 2026 17:33:26 -0400 Subject: [PATCH 099/130] zen: redeem go --- infra/console.ts | 1 - .../console/app/src/routes/stripe/webhook.ts | 7 ++++++ .../routes/workspace/[id]/billing/index.tsx | 2 +- packages/console/core/src/billing.ts | 23 +++++++++++++++---- packages/console/core/src/lite.ts | 8 ++----- packages/console/core/sst-env.d.ts | 4 ---- packages/console/function/sst-env.d.ts | 4 ---- packages/console/resource/sst-env.d.ts | 4 ---- packages/enterprise/sst-env.d.ts | 4 ---- packages/function/sst-env.d.ts | 4 ---- sst-env.d.ts | 4 ---- 11 files changed, 29 insertions(+), 36 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 8925f37d5ab..f1f5692b7ae 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", { SALESFORCE_INSTANCE_URL, ZEN_BLACK_PRICE, ZEN_LITE_PRICE, - new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"), new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_SESSION_SECRET"), ...ZEN_MODELS, diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 0d8cf61cfa8..c28d9ebbb9c 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js" import { Resource } from "@opencode-ai/console-resource" import { LiteData } from "@opencode-ai/console-core/lite.js" import { BlackData } from "@opencode-ai/console-core/black.js" +import { User } from "@opencode-ai/console-core/user.js" export async function POST(input: APIEvent) { const body = await Billing.stripe().webhooks.constructEventAsync( @@ -109,6 +110,8 @@ export async function POST(input: APIEvent) { if (type === "lite") { const workspaceID = body.data.object.metadata?.workspaceID const userID = body.data.object.metadata?.userID + const userEmail = body.data.object.metadata?.userEmail + const coupon = body.data.object.metadata?.coupon const customerID = body.data.object.customer as string const invoiceID = body.data.object.latest_invoice as string const subscriptionID = body.data.object.id as string @@ -156,6 +159,10 @@ export async function POST(input: APIEvent) { id: Identifier.create("lite"), userID: userID, }) + + if (userEmail && coupon === LiteData.firstMonth100Coupon) { + await Billing.redeemCoupon(userEmail, "GOFREEMONTH") + } }) }) } diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx index fb48485354b..11185436be0 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -25,9 +25,9 @@ export default function () { + -
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index d96b19ad79d..a3252f1d78c 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -1,5 +1,5 @@ import { Stripe } from "stripe" -import { and, Database, eq, sql } from "./drizzle" +import { and, Database, eq, isNull, sql } from "./drizzle" import { BillingTable, CouponTable, @@ -176,6 +176,16 @@ export namespace Billing { ) } + export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => { + return await Database.use((tx) => + tx + .select() + .from(CouponTable) + .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed))) + .then((rows) => rows.length > 0), + ) + } + export const setMonthlyLimit = fn(z.number(), async (input) => { return await Database.use((tx) => tx @@ -274,16 +284,19 @@ export namespace Billing { const user = Actor.assert("user") const { successUrl, cancelUrl, method } = input - const email = await User.getAuthEmail(user.properties.userID) + const email = (await User.getAuthEmail(user.properties.userID))! const billing = await Billing.get() if (billing.subscriptionID) throw new Error("Already subscribed to Black") if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite") + const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH")) + ? LiteData.firstMonth100Coupon + : LiteData.firstMonth50Coupon const createSession = () => Billing.stripe().checkout.sessions.create({ mode: "subscription", - discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }], + discounts: [{ coupon }], ...(billing.customerID ? { customer: billing.customerID, @@ -293,7 +306,7 @@ export namespace Billing { }, } : { - customer_email: email!, + customer_email: email, }), ...(() => { if (method === "alipay") { @@ -341,6 +354,8 @@ export namespace Billing { metadata: { workspaceID: Actor.workspace(), userID: user.properties.userID, + userEmail: email, + coupon, type: "lite", }, }, diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts index 3343192c19b..c0497766434 100644 --- a/packages/console/core/src/lite.ts +++ b/packages/console/core/src/lite.ts @@ -11,11 +11,7 @@ export namespace LiteData { export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product) export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price) export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr) - export const firstMonthCoupon = fn(z.string(), (email) => { - const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",") - return invitees.includes(email) - ? Resource.ZEN_LITE_PRICE.firstMonth100Coupon - : Resource.ZEN_LITE_PRICE.firstMonth50Coupon - }) + export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon + export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon export const planName = fn(z.void(), () => "lite") } diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index b77ee3c5bf5..bfba1b8f2e1 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -142,10 +142,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index b77ee3c5bf5..bfba1b8f2e1 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -142,10 +142,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index b77ee3c5bf5..bfba1b8f2e1 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -142,10 +142,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index b77ee3c5bf5..bfba1b8f2e1 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -142,10 +142,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index b77ee3c5bf5..bfba1b8f2e1 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -142,10 +142,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string diff --git a/sst-env.d.ts b/sst-env.d.ts index 2a40a9f3c93..e0c3665a964 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -168,10 +168,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { - "type": "sst.sst.Secret" - "value": string - } "ZEN_LITE_PRICE": { "firstMonth100Coupon": string "firstMonth50Coupon": string From 7e971d8302c3d2c0020bd81e94d3ca039b4fd9a9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 18 Apr 2026 21:37:45 +0000 Subject: [PATCH 100/130] chore: generate --- .../snapshot.json | 91 ++++--------------- 1 file changed, 20 insertions(+), 71 deletions(-) diff --git a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json index 40e9d5e1527..44ccb08cc8d 100644 --- a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json +++ b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json @@ -2,9 +2,7 @@ "version": "6", "dialect": "mysql", "id": "18b4281c-1609-47d8-9d51-0b08e3925f2b", - "prevIds": [ - "93c492af-c95b-4213-9fc2-38c3dd10374d" - ], + "prevIds": ["93c492af-c95b-4213-9fc2-38c3dd10374d"], "ddl": [ { "name": "account", @@ -2221,158 +2219,109 @@ "table": "workspace" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "auth", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "benchmark", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "billing", "entityType": "pks" }, { - "columns": [ - "email", - "type" - ], + "columns": ["email", "type"], "name": "PRIMARY", "table": "coupon", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "lite", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "payment", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "subscription", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "usage", "entityType": "pks" }, { - "columns": [ - "ip", - "interval" - ], + "columns": ["ip", "interval"], "name": "PRIMARY", "table": "ip_rate_limit", "entityType": "pks" }, { - "columns": [ - "ip" - ], + "columns": ["ip"], "name": "PRIMARY", "table": "ip", "entityType": "pks" }, { - "columns": [ - "key", - "interval" - ], + "columns": ["key", "interval"], "name": "PRIMARY", "table": "key_rate_limit", "entityType": "pks" }, { - "columns": [ - "key", - "interval" - ], + "columns": ["key", "interval"], "name": "PRIMARY", "table": "model_rate_limit", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "key", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "model", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "provider", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "user", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "workspace", "entityType": "pks" @@ -2667,4 +2616,4 @@ } ], "renames": [] -} \ No newline at end of file +} From 1d54b0e54065e3ad09093cff658447d0e2ffde3b Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 18 Apr 2026 18:30:28 -0400 Subject: [PATCH 101/130] Stefan/enterprise forms waitlist (#23158) Co-authored-by: Ryan Vogel --- .../console/app/src/routes/api/enterprise.ts | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts index 1bc4d0eb293..6560a936108 100644 --- a/packages/console/app/src/routes/api/enterprise.ts +++ b/packages/console/app/src/routes/api/enterprise.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { AWS } from "@opencode-ai/console-core/aws.js" +import { Resource } from "@opencode-ai/console-resource" import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { createLead } from "~/lib/salesforce" @@ -14,6 +15,64 @@ interface EnterpriseFormData { message: string } +const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43" + +function splitFullName(fullName: string) { + const parts = fullName + .trim() + .split(/\s+/) + .filter((p) => p.length > 0) + if (parts.length === 0) return { firstName: "", lastName: "" } + if (parts.length === 1) return { firstName: parts[0], lastName: "" } + return { firstName: parts[0], lastName: parts.slice(1).join(" ") } +} + +function getEmailOctopusApiKey() { + if (process.env.EMAILOCTOPUS_API_KEY) return process.env.EMAILOCTOPUS_API_KEY + try { + return Resource.EMAILOCTOPUS_API_KEY.value + } catch { + return + } +} + +function subscribe(email: string, fullName: string) { + const apiKey = getEmailOctopusApiKey() + if (!apiKey) { + console.warn("Skipping EmailOctopus subscribe: missing API key") + return Promise.resolve(false) + } + + const name = splitFullName(fullName) + const fields: Record = {} + if (name.firstName) fields.FirstName = name.firstName + if (name.lastName) fields.LastName = name.lastName + + const payload: { email_address: string; fields?: Record } = { email_address: email } + if (Object.keys(fields).length) payload.fields = fields + + return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, { + method: "PUT", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }).then( + (res) => { + if (!res.ok) { + console.error("EmailOctopus subscribe failed:", res.status, res.statusText) + return false + } + return true + }, + (err) => { + console.error("Failed to subscribe enterprise email:", err) + return false + }, + ) +} + export async function POST(event: APIEvent) { const dict = i18n(localeFromRequest(event.request)) try { @@ -41,7 +100,7 @@ ${body.role}
${body.company ? `${body.company}
` : ""}${body.email}
${body.phone ? `${body.phone}
` : ""}`.trim() - const [lead, mail] = await Promise.all([ + const [lead, mail, octopus] = await Promise.all([ createLead({ name: body.name, role: body.role, @@ -49,6 +108,9 @@ ${body.phone ? `${body.phone}
` : ""}`.trim() email: body.email, phone: body.phone, message: body.message, + }).catch((err) => { + console.error("Failed to create Salesforce lead:", err) + return false }), AWS.sendEmail({ to: "contact@anoma.ly", @@ -62,9 +124,14 @@ ${body.phone ? `${body.phone}
` : ""}`.trim() return false }, ), + subscribe(body.email, body.name), ]) - if (!lead && !mail) { + if (!lead && !mail && !octopus) { + if (import.meta.env.DEV) { + console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email }) + return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 }) + } console.error("Enterprise inquiry delivery failed", { email: body.email }) return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 }) } From 78ca49a1bcdfe42482cb8e902d0e9c3248a50d7a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:46:15 -0500 Subject: [PATCH 102/130] test: fix bedrock test (#23351) --- packages/opencode/test/provider/transform.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e195d9b1772..7a7631710d6 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2804,12 +2804,14 @@ describe("ProviderTransform.variants", () => { reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", + display: "summarized", }, }) expect(result.max).toEqual({ reasoningConfig: { type: "adaptive", maxReasoningEffort: "max", + display: "summarized", }, }) }) From 940f971ca0368fadd03d654ccab79594672f4c48 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 18:55:15 -0400 Subject: [PATCH 103/130] ci: fix --- script/publish.ts | 94 ++++++++++++++--------------------------------- 1 file changed, 28 insertions(+), 66 deletions(-) mode change 100755 => 100644 script/publish.ts diff --git a/script/publish.ts b/script/publish.ts old mode 100755 new mode 100644 index c8e39a4b3d5..d1141c8f7e7 --- a/script/publish.ts +++ b/script/publish.ts @@ -6,6 +6,9 @@ import { fileURLToPath } from "url" console.log("=== publishing ===\n") +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + const tag = `v${Script.version}` const pkgjsons = await Array.fromAsync( @@ -14,64 +17,29 @@ const pkgjsons = await Array.fromAsync( }), ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) -const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) -const publishPackageJsons = ["packages/plugin/package.json", "packages/sdk/js/package.json"] - -async function hasChanges() { - return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0 -} - -async function hasPublishPackageJsonChanges() { - if ((await $`git diff --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0) return true - return (await $`git diff --cached --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0 -} - -async function logPublishPackageJsonChanges() { - await $`git status --short -- ${publishPackageJsons}` - await $`git diff -- ${publishPackageJsons}` - await $`git diff --cached -- ${publishPackageJsons}` -} - -async function releaseTagExists() { - return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0 -} - -async function prepareReleaseFiles() { - for (const file of pkgjsons) { - let pkg = await Bun.file(file).text() - pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) - console.log("updated:", file) - await Bun.file(file).write(pkg) - } - - let toml = await Bun.file(extensionToml).text() - toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) - toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) - console.log("updated:", extensionToml) - await Bun.file(extensionToml).write(toml) - - await $`bun install` - await $`./packages/sdk/js/script/build.ts` -} - if (Script.release && !Script.preview) { await $`git fetch origin --tags` await $`git switch --detach` } -await prepareReleaseFiles() - -if (Script.release && !Script.preview) { - if (await releaseTagExists()) { - console.log(`release tag ${tag} already exists, skipping tag creation`) - } else { - await $`git commit -am "release: ${tag}"` - await $`git tag ${tag}` - await $`git push origin refs/tags/${tag} --no-verify` - await new Promise((resolve) => setTimeout(resolve, 5_000)) - } +for (const file of pkgjsons) { + let pkg = await Bun.file(file).text() + pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) + console.log("updated:", file) + await Bun.file(file).write(pkg) } +const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) +let toml = await Bun.file(extensionToml).text() +toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) +toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) +console.log("updated:", extensionToml) +await Bun.file(extensionToml).write(toml) + +await $`bun install` +await import(`../packages/sdk/js/script/build.ts`) +process.chdir(dir) + console.log("\n=== cli ===\n") await import(`../packages/opencode/script/publish.ts`) @@ -87,25 +55,19 @@ if (Script.release) { } if (Script.release && !Script.preview) { + process.chdir(dir) + await $`git commit -am "release: ${tag}"` + const releaseCommit = (await $`git rev-parse HEAD`.text()).trim() + await $`git tag -d ${tag}`.nothrow() + await $`git tag ${tag}` + await $`git push origin refs/tags/${tag} --force-with-lease --no-verify` + await new Promise((resolve) => setTimeout(resolve, 5_000)) await $`git fetch origin` - if (await hasPublishPackageJsonChanges()) { - console.error("publish scripts left package.json changes before syncing dev") - await logPublishPackageJsonChanges() - throw new Error("packages/plugin/package.json or packages/sdk/js/package.json changed during publish") - } await $`git checkout -B dev origin/dev` - await prepareReleaseFiles() - if (await hasChanges()) { - await $`git commit -am "sync release versions for v${Script.version}"` - await $`git push origin HEAD:dev --no-verify` - } else { - console.log(`dev already synced for ${tag}`) - } + await $`git cherry-pick ${releaseCommit}` + await $`git push origin HEAD:dev --no-verify` } if (Script.release) { await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}` } - -const dir = fileURLToPath(new URL("..", import.meta.url)) -process.chdir(dir) From cfbbae73236913794c2c63a469849cd6c88470a9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 18:59:31 -0400 Subject: [PATCH 104/130] ci --- script/publish.ts | 53 ++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/script/publish.ts b/script/publish.ts index d1141c8f7e7..4d90bd96874 100644 --- a/script/publish.ts +++ b/script/publish.ts @@ -8,7 +8,6 @@ console.log("=== publishing ===\n") const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) - const tag = `v${Script.version}` const pkgjsons = await Array.fromAsync( @@ -17,54 +16,56 @@ const pkgjsons = await Array.fromAsync( }), ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) +async function prepareReleaseFiles() { + for (const file of pkgjsons) { + let pkg = await Bun.file(file).text() + pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) + console.log("updated:", file) + await Bun.file(file).write(pkg) + } + + const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) + let toml = await Bun.file(extensionToml).text() + toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) + toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) + console.log("updated:", extensionToml) + await Bun.file(extensionToml).write(toml) + + await $`bun install` + await $`./packages/sdk/js/script/build.ts` +} + if (Script.release && !Script.preview) { await $`git fetch origin --tags` await $`git switch --detach` } -for (const file of pkgjsons) { - let pkg = await Bun.file(file).text() - pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) - console.log("updated:", file) - await Bun.file(file).write(pkg) -} - -const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url)) -let toml = await Bun.file(extensionToml).text() -toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`) -toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`) -console.log("updated:", extensionToml) -await Bun.file(extensionToml).write(toml) - -await $`bun install` -await import(`../packages/sdk/js/script/build.ts`) -process.chdir(dir) +await prepareReleaseFiles() console.log("\n=== cli ===\n") -await import(`../packages/opencode/script/publish.ts`) +await $`bun ./packages/opencode/script/publish.ts` console.log("\n=== sdk ===\n") -await import(`../packages/sdk/js/script/publish.ts`) +await $`bun ./packages/sdk/js/script/publish.ts` console.log("\n=== plugin ===\n") -await import(`../packages/plugin/script/publish.ts`) +await $`bun ./packages/plugin/script/publish.ts` if (Script.release) { - await import(`../packages/desktop/scripts/finalize-latest-json.ts`) - await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`) + await $`bun ./packages/desktop/scripts/finalize-latest-json.ts` + await $`bun ./packages/desktop-electron/scripts/finalize-latest-yml.ts` } if (Script.release && !Script.preview) { - process.chdir(dir) await $`git commit -am "release: ${tag}"` - const releaseCommit = (await $`git rev-parse HEAD`.text()).trim() await $`git tag -d ${tag}`.nothrow() await $`git tag ${tag}` await $`git push origin refs/tags/${tag} --force-with-lease --no-verify` await new Promise((resolve) => setTimeout(resolve, 5_000)) await $`git fetch origin` await $`git checkout -B dev origin/dev` - await $`git cherry-pick ${releaseCommit}` + await prepareReleaseFiles() + await $`git commit -am "sync release versions for ${tag}"` await $`git push origin HEAD:dev --no-verify` } From ad0545335a9ac5c371f4bd51674cd6da2414e2e9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 19:29:21 -0400 Subject: [PATCH 105/130] ci --- script/publish.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 script/publish.ts diff --git a/script/publish.ts b/script/publish.ts old mode 100644 new mode 100755 From f27eb8f09ef0d9071ebb4d1b11625b27f49a8939 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 20:02:24 -0400 Subject: [PATCH 106/130] fix plugins reinstalling too often --- packages/opencode/src/npm/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 0760e768b98..477e99e06af 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,6 +1,7 @@ export * as Npm from "." import path from "path" +import npa from "npm-package-arg" import semver from "semver" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" @@ -135,6 +136,17 @@ export const layer = Layer.effect( const add = Effect.fn("Npm.add")(function* (pkg: string) { const dir = directory(pkg) + const name = (() => { + try { + return npa(pkg).name ?? pkg + } catch { + return pkg + } + })() + + if (yield* afs.existsSafe(dir)) { + return resolveEntryPoint(name, path.join(dir, "node_modules", name)) + } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to From d1835686440d60686b7b9f66e160d47e747266da Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 18 Apr 2026 22:32:53 -0400 Subject: [PATCH 107/130] core: ensure executable permissions are set before Docker builds Fixes an issue where GitHub artifact downloads could strip executable bits from binaries, causing Docker builds to fail when using unpacked dist files directly rather than published tarballs. The chmod now runs before the publish check to guarantee binaries are executable. --- packages/opencode/script/publish.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index b444106a1b4..7557c475f7a 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -12,11 +12,13 @@ async function published(name: string, version: string) { } async function publish(dir: string, name: string, version: string) { + // GitHub artifact downloads can drop the executable bit, and Docker uses the + // unpacked dist binaries directly rather than the published tarball. + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) if (await published(name, version)) { console.log(`already published ${name}@${version}`) return } - if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) await $`bun pm pack`.cwd(dir) await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) } From a948b1c539c6324beebc016a8503d3bf4fce5e97 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 19 Apr 2026 03:02:03 +0000 Subject: [PATCH 108/130] release: v1.14.17 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 947e355d7f6..418af9dbb11 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -268,7 +268,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -297,7 +297,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -313,7 +313,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.11", + "version": "1.14.17", "bin": { "opencode": "./bin/opencode", }, @@ -458,7 +458,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -493,7 +493,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "cross-spawn": "catalog:", }, @@ -508,7 +508,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "1.4.11", + "version": "1.14.17", "bin": { "opencode": "./bin/opencode", }, @@ -532,7 +532,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -567,7 +567,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -616,7 +616,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 5a1a4504eab..7d21a9f95b3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.11", + "version": "1.14.17", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 200a5e30e3c..17dad030692 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f233726e691..f9a7ed89c41 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.11", + "version": "1.14.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 1142230bb7e..0217aba6682 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.11", + "version": "1.14.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 860150aa28c..3b7019246da 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.11", + "version": "1.14.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 8142b12ada3..5c9b6fdaccc 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a23342bdec3..006fcc5baa7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index f5651596289..7d2fc530cd7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.11", + "version": "1.14.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 32039c097ac..4b1b1ca722a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.11" +version = "1.14.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 5d4229f64f4..0d654f6041e 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.11", + "version": "1.14.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 40f25f8f7b6..dc20ecfc530 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.11", + "version": "1.14.17", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 15cd2db6e24..d62a41bf729 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 91d66474499..02b168cf395 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index a8cd62886be..d658aaa4685 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.11", + "version": "1.14.17", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 8ca990ba58f..a5d99a0d186 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 98cb928b7bd..aedde0a4fa5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.11", + "version": "1.14.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 194f44ec03d..51c09f10be6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.11", + "version": "1.14.17", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f52135c2067..474c1a9141d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.11", + "version": "1.14.17", "publisher": "sst-dev", "repository": { "type": "git", From 3f507b9427a5917bbd448fff03713d0eded147c5 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Thu, 23 Apr 2026 15:22:41 +0200 Subject: [PATCH 109/130] refactor: kilo compat for v1.14.17 --- README.ar.md | 4 +- README.bn.md | 4 +- README.br.md | 4 +- README.bs.md | 4 +- README.da.md | 4 +- README.de.md | 4 +- README.es.md | 4 +- README.fr.md | 4 +- README.gr.md | 4 +- README.it.md | 4 +- README.ja.md | 4 +- README.ko.md | 4 +- README.no.md | 4 +- README.pl.md | 4 +- README.ru.md | 4 +- README.th.md | 4 +- README.tr.md | 4 +- README.uk.md | 4 +- README.vi.md | 4 +- README.zh.md | 4 +- README.zht.md | 4 +- github/index.ts | 8 +- github/package.json | 5 +- infra/enterprise.ts | 10 +- package.json | 57 +++++--- packages/app/package.json | 11 +- packages/app/playwright.config.ts | 4 +- .../components/dialog-connect-provider.tsx | 2 +- .../src/components/dialog-custom-provider.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 2 +- packages/app/src/components/file-tree.tsx | 2 +- .../prompt-input/build-request-parts.ts | 2 +- .../components/prompt-input/submit.test.ts | 4 +- .../app/src/components/prompt-input/submit.ts | 2 +- .../session/session-context-breakdown.test.ts | 2 +- .../session/session-context-breakdown.ts | 2 +- .../session/session-context-metrics.test.ts | 2 +- .../session/session-context-metrics.ts | 2 +- .../session/session-context-tab.tsx | 2 +- .../src/components/session/session-header.tsx | 2 +- .../app/src/components/settings-general.tsx | 4 +- packages/app/src/components/titlebar.tsx | 6 +- .../app/src/context/file/content-cache.ts | 2 +- packages/app/src/context/file/tree-store.ts | 2 +- packages/app/src/context/file/types.ts | 2 +- packages/app/src/context/file/watcher.ts | 2 +- packages/app/src/context/global-sdk.tsx | 2 +- packages/app/src/context/global-sync.tsx | 6 +- .../app/src/context/global-sync/bootstrap.ts | 10 +- .../src/context/global-sync/child-store.ts | 2 +- .../context/global-sync/event-reducer.test.ts | 2 +- .../src/context/global-sync/event-reducer.ts | 2 +- .../context/global-sync/session-cache.test.ts | 2 +- .../src/context/global-sync/session-cache.ts | 2 +- .../context/global-sync/session-trim.test.ts | 2 +- .../src/context/global-sync/session-trim.ts | 2 +- packages/app/src/context/global-sync/types.ts | 2 +- .../app/src/context/global-sync/utils.test.ts | 2 +- packages/app/src/context/global-sync/utils.ts | 2 +- packages/app/src/context/highlights.tsx | 2 +- packages/app/src/context/layout.tsx | 2 +- packages/app/src/context/notification.tsx | 2 +- .../context/permission-auto-respond.test.ts | 2 +- packages/app/src/context/permission.tsx | 2 +- packages/app/src/context/sdk.tsx | 2 +- .../app/src/context/sync-optimistic.test.ts | 2 +- packages/app/src/context/sync.tsx | 2 +- packages/app/src/entry.tsx | 2 +- packages/app/src/env.d.ts | 6 +- packages/app/src/i18n/ar.ts | 34 ++--- packages/app/src/i18n/br.ts | 34 ++--- packages/app/src/i18n/bs.ts | 34 ++--- packages/app/src/i18n/da.ts | 34 ++--- packages/app/src/i18n/de.ts | 34 ++--- packages/app/src/i18n/en.ts | 34 ++--- packages/app/src/i18n/es.ts | 34 ++--- packages/app/src/i18n/fr.ts | 34 ++--- packages/app/src/i18n/ja.ts | 34 ++--- packages/app/src/i18n/ko.ts | 34 ++--- packages/app/src/i18n/no.ts | 34 ++--- packages/app/src/i18n/pl.ts | 34 ++--- packages/app/src/i18n/ru.ts | 34 ++--- packages/app/src/i18n/th.ts | 34 ++--- packages/app/src/i18n/tr.ts | 34 ++--- packages/app/src/i18n/zh.ts | 34 ++--- packages/app/src/i18n/zht.ts | 34 ++--- packages/app/src/pages/error.tsx | 2 +- packages/app/src/pages/layout.tsx | 4 +- packages/app/src/pages/layout/helpers.test.ts | 2 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 6 +- .../src/pages/layout/sidebar-workspace.tsx | 2 +- packages/app/src/pages/session.tsx | 2 +- .../composer/session-composer-state.test.ts | 2 +- .../composer/session-composer-state.ts | 2 +- .../composer/session-permission-dock.tsx | 2 +- .../composer/session-question-dock.tsx | 2 +- .../session/composer/session-request-tree.ts | 2 +- .../session/composer/session-todo-dock.tsx | 2 +- .../src/pages/session/message-timeline.tsx | 2 +- packages/app/src/pages/session/review-tab.tsx | 2 +- .../session/session-model-helpers.test.ts | 2 +- .../pages/session/session-model-helpers.ts | 2 +- .../src/pages/session/session-side-panel.tsx | 4 +- .../pages/session/use-session-commands.tsx | 4 +- .../pages/session/use-session-hash-scroll.ts | 2 +- packages/app/src/utils/diffs.test.ts | 4 +- packages/app/src/utils/diffs.ts | 4 +- packages/app/src/utils/prompt.test.ts | 2 +- packages/app/src/utils/prompt.ts | 2 +- packages/app/src/utils/server.ts | 6 +- packages/console/app/package.json | 2 +- packages/console/app/src/lib/language.ts | 2 +- .../app/src/routes/zen/util/handler.ts | 16 +-- .../zen/util/provider/openai-compatible.ts | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/resource/package.json | 3 +- .../electron-builder.config.ts | 2 +- .../desktop-electron/electron.vite.config.ts | 14 +- packages/desktop-electron/package.json | 17 ++- .../scripts/finalize-latest-yml.ts | 4 +- packages/desktop-electron/scripts/predev.ts | 2 +- packages/desktop-electron/scripts/utils.ts | 2 +- .../desktop-electron/src/main/constants.ts | 2 +- packages/desktop-electron/src/main/env.d.ts | 2 +- packages/desktop-electron/src/main/index.ts | 6 +- packages/desktop-electron/src/main/server.ts | 10 +- .../src/main/shell-env.test.ts | 4 +- packages/desktop-electron/src/main/windows.ts | 2 +- .../desktop-electron/src/renderer/index.tsx | 16 +-- .../desktop-electron/src/renderer/updater.ts | 2 +- packages/desktop/package.json | 2 +- .../desktop/scripts/finalize-latest-json.ts | 8 +- packages/desktop/scripts/prepare.ts | 2 +- packages/desktop/src-tauri/Cargo.toml | 4 +- packages/desktop/src-tauri/src/cli.rs | 32 ++--- packages/desktop/src-tauri/src/constants.rs | 2 +- packages/desktop/src-tauri/src/lib.rs | 8 +- .../desktop/src-tauri/src/linux_display.rs | 4 +- packages/desktop/src-tauri/src/main.rs | 2 +- packages/desktop/src-tauri/src/windows.rs | 6 +- packages/desktop/src-tauri/tauri.conf.json | 8 +- .../desktop/src-tauri/tauri.prod.conf.json | 6 +- packages/desktop/src/i18n/ar.ts | 10 +- packages/desktop/src/i18n/br.ts | 10 +- packages/desktop/src/i18n/bs.ts | 10 +- packages/desktop/src/i18n/da.ts | 10 +- packages/desktop/src/i18n/de.ts | 10 +- packages/desktop/src/i18n/en.ts | 10 +- packages/desktop/src/i18n/es.ts | 10 +- packages/desktop/src/i18n/fr.ts | 10 +- packages/desktop/src/i18n/ja.ts | 10 +- packages/desktop/src/i18n/ko.ts | 10 +- packages/desktop/src/i18n/no.ts | 10 +- packages/desktop/src/i18n/pl.ts | 10 +- packages/desktop/src/i18n/ru.ts | 10 +- packages/desktop/src/i18n/zh.ts | 10 +- packages/desktop/src/i18n/zht.ts | 10 +- packages/desktop/src/index.tsx | 12 +- packages/desktop/src/menu.ts | 6 +- packages/desktop/src/updater.ts | 2 +- packages/enterprise/package.json | 4 +- packages/enterprise/src/core/share.ts | 2 +- packages/enterprise/src/core/storage.ts | 18 +-- .../enterprise/src/routes/share/[shareID].tsx | 2 +- packages/enterprise/vite.config.ts | 4 +- packages/extensions/zed/extension.toml | 18 +-- packages/function/package.json | 2 +- packages/opencode/drizzle.config.ts | 2 +- packages/opencode/package.json | 33 +++-- packages/opencode/script/build-node.ts | 4 +- packages/opencode/script/build.ts | 12 +- packages/opencode/script/generate.ts | 2 +- packages/opencode/script/publish.ts | 18 +-- packages/opencode/script/trace-imports.ts | 2 +- packages/opencode/specs/effect/http-api.md | 14 +- .../opencode/specs/effect/server-package.md | 2 +- packages/opencode/specs/tui-plugins.md | 14 +- packages/opencode/src/acp/README.md | 2 +- packages/opencode/src/acp/agent.ts | 10 +- packages/opencode/src/acp/session.ts | 6 +- packages/opencode/src/acp/types.ts | 4 +- packages/opencode/src/auth/index.ts | 4 +- packages/opencode/src/cli/cmd/acp.ts | 6 +- packages/opencode/src/cli/cmd/generate.ts | 4 +- packages/opencode/src/cli/cmd/import.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 20 +-- packages/opencode/src/cli/cmd/serve.ts | 4 +- packages/opencode/src/cli/cmd/session.ts | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 12 +- packages/opencode/src/cli/cmd/tui/attach.ts | 4 +- .../cmd/tui/component/dialog-console-org.tsx | 2 +- .../cli/cmd/tui/component/dialog-provider.tsx | 2 +- .../cmd/tui/component/dialog-session-list.tsx | 4 +- .../tui/component/dialog-workspace-create.tsx | 4 +- .../cli/cmd/tui/component/prompt/history.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 2 +- .../src/cli/cmd/tui/config/tui-migrate.ts | 2 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 18 +-- .../opencode/src/cli/cmd/tui/context/event.ts | 2 +- .../src/cli/cmd/tui/context/project.tsx | 2 +- .../src/cli/cmd/tui/context/route.tsx | 4 +- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 10 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 2 +- .../cmd/tui/feature-plugins/home/footer.tsx | 2 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 2 +- .../tui/feature-plugins/sidebar/context.tsx | 4 +- .../cmd/tui/feature-plugins/sidebar/files.tsx | 2 +- .../tui/feature-plugins/sidebar/footer.tsx | 2 +- .../cmd/tui/feature-plugins/sidebar/lsp.tsx | 2 +- .../cmd/tui/feature-plugins/sidebar/mcp.tsx | 2 +- .../cmd/tui/feature-plugins/sidebar/todo.tsx | 2 +- .../tui/feature-plugins/system/plugins.tsx | 2 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 2 +- .../src/cli/cmd/tui/plugin/internal.ts | 2 +- .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- .../opencode/src/cli/cmd/tui/plugin/slots.tsx | 2 +- .../session/dialog-fork-from-timeline.tsx | 2 +- .../tui/routes/session/dialog-timeline.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- .../cli/cmd/tui/routes/session/question.tsx | 2 +- .../tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/cli/cmd/tui/thread.ts | 14 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 4 +- .../opencode/src/cli/cmd/tui/util/model.ts | 2 +- .../src/cli/cmd/tui/util/transcript.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/cli/cmd/web.ts | 4 +- packages/opencode/src/cli/heap.ts | 2 +- packages/opencode/src/cli/upgrade.ts | 4 +- packages/opencode/src/config/config.ts | 38 ++--- packages/opencode/src/config/managed.ts | 2 +- packages/opencode/src/config/paths.ts | 4 +- .../opencode/src/control-plane/workspace.ts | 8 +- packages/opencode/src/effect/observability.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 6 +- packages/opencode/src/file/watcher.ts | 8 +- packages/opencode/src/flag/flag.ts | 132 +++++++++--------- packages/opencode/src/format/formatter.ts | 2 +- packages/opencode/src/global/index.ts | 4 +- packages/opencode/src/ide/index.ts | 2 +- packages/opencode/src/index.ts | 6 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/installation/version.ts | 8 +- packages/opencode/src/lsp/lsp.ts | 4 +- packages/opencode/src/lsp/server.ts | 46 +++--- packages/opencode/src/plugin/cloudflare.ts | 2 +- packages/opencode/src/plugin/codex.ts | 2 +- .../src/plugin/github-copilot/copilot.ts | 4 +- .../src/plugin/github-copilot/models.ts | 2 +- packages/opencode/src/plugin/index.ts | 14 +- packages/opencode/src/plugin/meta.ts | 2 +- packages/opencode/src/project/project.ts | 4 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/models.ts | 10 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/src/pty/index.ts | 2 +- packages/opencode/src/server/fence.ts | 2 +- packages/opencode/src/server/middleware.ts | 4 +- packages/opencode/src/server/proxy.ts | 8 +- .../server/routes/instance/httpapi/server.ts | 10 +- .../src/server/routes/instance/index.ts | 2 +- .../src/server/routes/instance/middleware.ts | 2 +- packages/opencode/src/server/routes/ui.ts | 2 +- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/server/workspace.ts | 4 +- packages/opencode/src/session/instruction.ts | 18 +-- packages/opencode/src/session/llm.ts | 8 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/session.ts | 4 +- packages/opencode/src/share/session.ts | 2 +- packages/opencode/src/share/share-next.ts | 4 +- packages/opencode/src/shell/shell.ts | 2 +- packages/opencode/src/skill/index.ts | 6 +- packages/opencode/src/storage/db.ts | 20 +-- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/registry.ts | 10 +- .../opencode/src/util/opencode-process.ts | 8 +- .../test/acp/event-subscription.test.ts | 2 +- packages/opencode/test/agent/agent.test.ts | 6 +- .../test/cli/plugin-auth-picker.test.ts | 2 +- .../opencode/test/cli/tui/plugin-add.test.ts | 8 +- .../test/cli/tui/plugin-install.test.ts | 4 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 32 ++--- .../test/cli/tui/plugin-loader-pure.test.ts | 16 +-- .../test/cli/tui/plugin-loader.test.ts | 14 +- .../test/cli/tui/plugin-toggle.test.ts | 8 +- .../opencode/test/cli/tui/transcript.test.ts | 2 +- .../opencode/test/cli/tui/use-event.test.tsx | 2 +- packages/opencode/test/config/config.test.ts | 116 +++++++-------- packages/opencode/test/config/tui.test.ts | 16 +-- .../test/effect/cross-spawn-spawner.test.ts | 6 +- .../test/effect/observability.test.ts | 8 +- packages/opencode/test/file/watcher.test.ts | 4 +- .../test/fixture/plugin-meta-worker.ts | 2 +- packages/opencode/test/fixture/tui-plugin.ts | 4 +- packages/opencode/test/fixture/tui-runtime.ts | 4 +- packages/opencode/test/ide/ide.test.ts | 12 +- .../test/plugin/loader-shared.test.ts | 16 +-- packages/opencode/test/plugin/meta.test.ts | 14 +- packages/opencode/test/plugin/trigger.test.ts | 8 +- .../test/plugin/workspace-adaptor.test.ts | 14 +- packages/opencode/test/preload.ts | 14 +- .../test/server/project-init-git.test.ts | 8 +- .../test/server/session-messages.test.ts | 8 +- .../opencode/test/session/instruction.test.ts | 28 ++-- packages/opencode/test/session/system.test.ts | 6 +- packages/opencode/test/skill/skill.test.ts | 6 +- packages/opencode/test/storage/db.test.ts | 2 +- packages/opencode/test/sync/index.test.ts | 6 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/registry.test.ts | 4 +- packages/opencode/test/tool/skill.test.ts | 6 +- packages/opencode/test/util/process.test.ts | 10 +- .../test/workspace/workspace-restore.test.ts | 6 +- packages/plugin/package.json | 11 +- packages/plugin/src/example-workspace.ts | 2 +- packages/plugin/src/index.ts | 8 +- packages/plugin/src/tui.ts | 6 +- packages/script/package.json | 4 +- packages/script/src/index.ts | 20 +-- packages/sdk/js/example/example.ts | 6 +- packages/sdk/js/package.json | 10 +- packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/client.ts | 14 +- packages/sdk/js/src/gen/sdk.gen.ts | 2 +- packages/sdk/js/src/index.ts | 10 +- packages/sdk/js/src/process.ts | 2 +- packages/sdk/js/src/server.ts | 8 +- packages/sdk/js/src/v2/client.ts | 20 +-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +- packages/sdk/js/src/v2/index.ts | 10 +- packages/sdk/js/src/v2/server.ts | 8 +- packages/shared/package.json | 2 +- packages/shared/src/global.ts | 2 +- packages/slack/package.json | 4 +- packages/slack/src/index.ts | 4 +- packages/storybook/package.json | 5 +- packages/ui/package.json | 12 +- packages/ui/src/components/favicon.tsx | 2 +- packages/ui/src/components/file-media.tsx | 2 +- packages/ui/src/components/font.stories.tsx | 4 +- packages/ui/src/components/icon.stories.tsx | 2 +- packages/ui/src/components/logo.stories.tsx | 2 +- .../ui/src/components/message-file.test.ts | 2 +- packages/ui/src/components/message-file.ts | 2 +- packages/ui/src/components/message-nav.tsx | 2 +- packages/ui/src/components/message-part.tsx | 2 +- packages/ui/src/components/session-diff.ts | 2 +- packages/ui/src/components/session-retry.tsx | 2 +- packages/ui/src/components/session-review.tsx | 2 +- packages/ui/src/components/session-turn.tsx | 4 +- .../timeline-playground.stories.tsx | 2 +- .../components/todo-panel-motion.stories.tsx | 2 +- packages/ui/src/context/data.tsx | 2 +- packages/ui/src/context/marked.tsx | 12 +- packages/ui/src/pierre/media.ts | 2 +- packages/ui/vite.config.ts | 2 +- packages/web/package.json | 2 +- script/duplicate-pr.ts | 4 +- script/raw-changelog.ts | 2 +- script/stats.ts | 4 +- sdks/vscode/package.json | 8 +- sdks/vscode/src/extension.ts | 6 +- turbo.json | 4 +- 372 files changed, 1440 insertions(+), 1380 deletions(-) diff --git a/README.ar.md b/README.ar.md index beb44589e62..850c24b5122 100644 --- a/README.ar.md +++ b/README.ar.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop يحترم سكربت التثبيت ترتيب الاولوية التالي لمسار التثبيت: -1. `$OPENCODE_INSTALL_DIR` - مجلد تثبيت مخصص +1. `$KILO_INSTALL_DIR` - مجلد تثبيت مخصص 2. `$XDG_BIN_DIR` - مسار متوافق مع مواصفات XDG Base Directory 3. `$HOME/bin` - مجلد الثنائيات القياسي للمستخدم (ان وجد او امكن انشاؤه) 4. `$HOME/.opencode/bin` - المسار الافتراضي الاحتياطي ```bash # امثلة -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.bn.md b/README.bn.md index c7abc7346a2..a59385ceb49 100644 --- a/README.bn.md +++ b/README.bn.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop ইনস্টল স্ক্রিপ্টটি ইনস্টলেশন পাতের জন্য নিম্নলিখিত অগ্রাধিকার ক্রম মেনে চলে: -1. `$OPENCODE_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি +1. `$KILO_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি 2. `$XDG_BIN_DIR` - XDG বেস ডিরেক্টরি স্পেসিফিকেশন সমর্থিত পাথ 3. `$HOME/bin` - সাধারণ ব্যবহারকারী বাইনারি ডিরেক্টরি (যদি বিদ্যমান থাকে বা তৈরি করা যায়) 4. `$HOME/.opencode/bin` - ডিফল্ট ফলব্যাক ```bash # উদাহরণ -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.br.md b/README.br.md index 6d1de21562c..c2508f2b5d1 100644 --- a/README.br.md +++ b/README.br.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop O script de instalação respeita a seguinte ordem de prioridade para o caminho de instalação: -1. `$OPENCODE_INSTALL_DIR` - Diretório de instalação personalizado +1. `$KILO_INSTALL_DIR` - Diretório de instalação personalizado 2. `$XDG_BIN_DIR` - Caminho compatível com a especificação XDG Base Directory 3. `$HOME/bin` - Diretório binário padrão do usuário (se existir ou puder ser criado) 4. `$HOME/.opencode/bin` - Fallback padrão ```bash # Exemplos -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.bs.md b/README.bs.md index 2cff8e0279c..a4923b82046 100644 --- a/README.bs.md +++ b/README.bs.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije: -1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij +1. `$KILO_INSTALL_DIR` - Prilagođeni instalacijski direktorij 2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom 3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati) 4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija ```bash # Primjeri -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.da.md b/README.da.md index ac522f29c49..47a9f41a05a 100644 --- a/README.da.md +++ b/README.da.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Installationsscriptet bruger følgende prioriteringsrækkefølge for installationsstien: -1. `$OPENCODE_INSTALL_DIR` - Tilpasset installationsmappe +1. `$KILO_INSTALL_DIR` - Tilpasset installationsmappe 2. `$XDG_BIN_DIR` - Sti der følger XDG Base Directory Specification 3. `$HOME/bin` - Standard bruger-bin-mappe (hvis den findes eller kan oprettes) 4. `$HOME/.opencode/bin` - Standard fallback ```bash # Eksempler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.de.md b/README.de.md index 87a670f3fce..ee324385f5c 100644 --- a/README.de.md +++ b/README.de.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Das Installationsskript beachtet die folgende Prioritätsreihenfolge für den Installationspfad: -1. `$OPENCODE_INSTALL_DIR` - Benutzerdefiniertes Installationsverzeichnis +1. `$KILO_INSTALL_DIR` - Benutzerdefiniertes Installationsverzeichnis 2. `$XDG_BIN_DIR` - XDG Base Directory Specification-konformer Pfad 3. `$HOME/bin` - Standard-Binärverzeichnis des Users (falls vorhanden oder erstellbar) 4. `$HOME/.opencode/bin` - Standard-Fallback ```bash # Beispiele -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.es.md b/README.es.md index 9e456af1c0b..233cec3628a 100644 --- a/README.es.md +++ b/README.es.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop El script de instalación respeta el siguiente orden de prioridad para la ruta de instalación: -1. `$OPENCODE_INSTALL_DIR` - Directorio de instalación personalizado +1. `$KILO_INSTALL_DIR` - Directorio de instalación personalizado 2. `$XDG_BIN_DIR` - Ruta compatible con la especificación XDG Base Directory 3. `$HOME/bin` - Directorio binario estándar del usuario (si existe o se puede crear) 4. `$HOME/.opencode/bin` - Alternativa por defecto ```bash # Ejemplos -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.fr.md b/README.fr.md index c1fca23376d..a147206047c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Le script d'installation respecte l'ordre de priorité suivant pour le chemin d'installation : -1. `$OPENCODE_INSTALL_DIR` - Répertoire d'installation personnalisé +1. `$KILO_INSTALL_DIR` - Répertoire d'installation personnalisé 2. `$XDG_BIN_DIR` - Chemin conforme à la spécification XDG Base Directory 3. `$HOME/bin` - Répertoire binaire utilisateur standard (s'il existe ou peut être créé) 4. `$HOME/.opencode/bin` - Repli par défaut ```bash # Exemples -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.gr.md b/README.gr.md index 2b2c2679d8e..2e047ac1253 100644 --- a/README.gr.md +++ b/README.gr.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Το script εγκατάστασης τηρεί την ακόλουθη σειρά προτεραιότητας για τη διαδρομή εγκατάστασης: -1. `$OPENCODE_INSTALL_DIR` - Προσαρμοσμένος κατάλογος εγκατάστασης +1. `$KILO_INSTALL_DIR` - Προσαρμοσμένος κατάλογος εγκατάστασης 2. `$XDG_BIN_DIR` - Διαδρομή συμβατή με τις προδιαγραφές XDG Base Directory 3. `$HOME/bin` - Τυπικός κατάλογος εκτελέσιμων αρχείων χρήστη (εάν υπάρχει ή μπορεί να δημιουργηθεί) 4. `$HOME/.opencode/bin` - Προεπιλεγμένη εφεδρική διαδρομή ```bash # Παραδείγματα -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.it.md b/README.it.md index 3e516a90270..d748c4346f4 100644 --- a/README.it.md +++ b/README.it.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Lo script di installazione rispetta il seguente ordine di priorità per il percorso di installazione: -1. `$OPENCODE_INSTALL_DIR` – Directory di installazione personalizzata +1. `$KILO_INSTALL_DIR` – Directory di installazione personalizzata 2. `$XDG_BIN_DIR` – Percorso conforme alla XDG Base Directory Specification 3. `$HOME/bin` – Directory binaria standard dell’utente (se esiste o può essere creata) 4. `$HOME/.opencode/bin` – Fallback predefinito ```bash # Esempi -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.ja.md b/README.ja.md index 144dc7b6f8a..a8325fc0ecc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop インストールスクリプトは、インストール先パスを次の優先順位で決定します。 -1. `$OPENCODE_INSTALL_DIR` - カスタムのインストールディレクトリ +1. `$KILO_INSTALL_DIR` - カスタムのインストールディレクトリ 2. `$XDG_BIN_DIR` - XDG Base Directory Specification に準拠したパス 3. `$HOME/bin` - 標準のユーザー用バイナリディレクトリ(存在する場合、または作成できる場合) 4. `$HOME/.opencode/bin` - デフォルトのフォールバック ```bash # 例 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.ko.md b/README.ko.md index 32defc0a5e0..edd3b96f12a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop 설치 스크립트는 설치 경로를 다음 우선순위로 결정합니다. -1. `$OPENCODE_INSTALL_DIR` - 사용자 지정 설치 디렉터리 +1. `$KILO_INSTALL_DIR` - 사용자 지정 설치 디렉터리 2. `$XDG_BIN_DIR` - XDG Base Directory Specification 준수 경로 3. `$HOME/bin` - 표준 사용자 바이너리 디렉터리 (존재하거나 생성 가능할 경우) 4. `$HOME/.opencode/bin` - 기본 폴백 ```bash # 예시 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.no.md b/README.no.md index c3348286b29..886ff57219d 100644 --- a/README.no.md +++ b/README.no.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Installasjonsskriptet bruker følgende prioritet for installasjonsstien: -1. `$OPENCODE_INSTALL_DIR` - Egendefinert installasjonsmappe +1. `$KILO_INSTALL_DIR` - Egendefinert installasjonsmappe 2. `$XDG_BIN_DIR` - Sti som følger XDG Base Directory Specification 3. `$HOME/bin` - Standard brukerbinar-mappe (hvis den finnes eller kan opprettes) 4. `$HOME/.opencode/bin` - Standard fallback ```bash # Eksempler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.pl.md b/README.pl.md index 4c5a0766561..5a60e0b2870 100644 --- a/README.pl.md +++ b/README.pl.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Skrypt instalacyjny stosuje następujący priorytet wyboru ścieżki instalacji: -1. `$OPENCODE_INSTALL_DIR` - Własny katalog instalacji +1. `$KILO_INSTALL_DIR` - Własny katalog instalacji 2. `$XDG_BIN_DIR` - Ścieżka zgodna ze specyfikacją XDG Base Directory 3. `$HOME/bin` - Standardowy katalog binarny użytkownika (jeśli istnieje lub można go utworzyć) 4. `$HOME/.opencode/bin` - Domyślny fallback ```bash # Przykłady -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.ru.md b/README.ru.md index e507be70e65..b68a03907b2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Скрипт установки выбирает путь установки в следующем порядке приоритета: -1. `$OPENCODE_INSTALL_DIR` - Пользовательский каталог установки +1. `$KILO_INSTALL_DIR` - Пользовательский каталог установки 2. `$XDG_BIN_DIR` - Путь, совместимый со спецификацией XDG Base Directory 3. `$HOME/bin` - Стандартный каталог пользовательских бинарников (если существует или можно создать) 4. `$HOME/.opencode/bin` - Fallback по умолчанию ```bash # Примеры -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.th.md b/README.th.md index 4a4ea62c957..4b90a8b921c 100644 --- a/README.th.md +++ b/README.th.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop สคริปต์การติดตั้งจะใช้ลำดับความสำคัญตามเส้นทางการติดตั้ง: -1. `$OPENCODE_INSTALL_DIR` - ไดเรกทอรีการติดตั้งที่กำหนดเอง +1. `$KILO_INSTALL_DIR` - ไดเรกทอรีการติดตั้งที่กำหนดเอง 2. `$XDG_BIN_DIR` - เส้นทางที่สอดคล้องกับ XDG Base Directory Specification 3. `$HOME/bin` - ไดเรกทอรีไบนารีผู้ใช้มาตรฐาน (หากมีอยู่หรือสามารถสร้างได้) 4. `$HOME/.opencode/bin` - ค่าสำรองเริ่มต้น ```bash # ตัวอย่าง -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.tr.md b/README.tr.md index e88b40f8751..d3b474a07bc 100644 --- a/README.tr.md +++ b/README.tr.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder: -1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini +1. `$KILO_INSTALL_DIR` - Özel kurulum dizini 2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol 3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa) 4. `$HOME/.opencode/bin` - Varsayılan yedek konum ```bash # Örnekler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.uk.md b/README.uk.md index a1a0259b6d0..1471aa6fdd8 100644 --- a/README.uk.md +++ b/README.uk.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення: -1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення +1. `$KILO_INSTALL_DIR` - Користувацький каталог встановлення 2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory 3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити) 4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням ```bash # Приклади -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.vi.md b/README.vi.md index 0932c50f78a..a4eaf22301a 100644 --- a/README.vi.md +++ b/README.vi.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt: -1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh +1. `$KILO_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh 2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification 3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo) 4. `$HOME/.opencode/bin` - Mặc định dự phòng ```bash # Ví dụ -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.zh.md b/README.zh.md index 46d9f761cbd..928378213d2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop 安装脚本按照以下优先级决定安装路径: -1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录 +1. `$KILO_INSTALL_DIR` - 自定义安装目录 2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径 3. `$HOME/bin` - 如果存在或可创建的用户二进制目录 4. `$HOME/.opencode/bin` - 默认备用路径 ```bash # 示例 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/README.zht.md b/README.zht.md index 7ef51d8fdda..88c1c545651 100644 --- a/README.zht.md +++ b/README.zht.md @@ -86,14 +86,14 @@ scoop bucket add extras; scoop install extras/opencode-desktop 安裝腳本會依據以下優先順序決定安裝路徑: -1. `$OPENCODE_INSTALL_DIR` - 自定義安裝目錄 +1. `$KILO_INSTALL_DIR` - 自定義安裝目錄 2. `$XDG_BIN_DIR` - 符合 XDG 基礎目錄規範的路徑 3. `$HOME/bin` - 標準使用者執行檔目錄 (若存在或可建立) 4. `$HOME/.opencode/bin` - 預設備用路徑 ```bash # 範例 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +KILO_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` diff --git a/github/index.ts b/github/index.ts index 51ee2a46a53..38a61086a5f 100644 --- a/github/index.ts +++ b/github/index.ts @@ -6,7 +6,7 @@ import * as core from "@actions/core" import * as github from "@actions/github" import type { Context as GitHubContext } from "@actions/github/lib/context" import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" -import { createOpencodeClient } from "@opencode-ai/sdk" +import { createKiloClient } from "@kilocode/sdk" import { spawn } from "node:child_process" import { setTimeout as sleep } from "node:timers/promises" @@ -113,7 +113,7 @@ type IssueQueryResponse = { } } -const { client, server } = createOpencode() +const { client, server } = createKilo() let accessToken: string let octoRest: Octokit let octoGraph: typeof graphql @@ -228,12 +228,12 @@ try { } process.exit(exitCode) -function createOpencode() { +function createKilo() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) - const client = createOpencodeClient({ baseUrl: url }) + const client = createKiloClient({ baseUrl: url }) return { server: { url, close: () => proc.kill() }, diff --git a/github/package.json b/github/package.json index e1b913abedc..c2001ffc068 100644 --- a/github/package.json +++ b/github/package.json @@ -15,6 +15,7 @@ "@actions/github": "6.0.1", "@octokit/graphql": "9.0.1", "@octokit/rest": "catalog:", - "@opencode-ai/sdk": "workspace:*" - } + "@kilocode/sdk": "workspace:*" + }, + "version": "7.2.20" } diff --git a/infra/enterprise.ts b/infra/enterprise.ts index dc336a68431..c1c1ca68dea 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -8,10 +8,10 @@ new sst.cloudflare.x.SolidStart("Teams", { path: "packages/enterprise", buildCommand: "bun run build:cloudflare", environment: { - OPENCODE_STORAGE_ADAPTER: "r2", - OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, - OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value, - OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value, - OPENCODE_STORAGE_BUCKET: storage.name, + KILO_STORAGE_ADAPTER: "r2", + KILO_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, + KILO_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value, + KILO_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value, + KILO_STORAGE_BUCKET: storage.name, }, }) diff --git a/package.json b/package.json index ddd711adaf4..0408b101e80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "opencode", + "name": "@kilocode/kilo", "description": "AI-powered development tool", "private": true, "type": "module", @@ -17,14 +17,13 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1" + "test": "echo 'do not run tests from root' && exit 1", + "extension": "bun --cwd packages/kilo-vscode script/launch.ts" }, "workspaces": { "packages": [ "packages/*", - "packages/console/*", - "packages/sdk/js", - "packages/slack" + "packages/sdk/js" ], "catalog": { "@effect/opentelemetry": "4.0.0-beta.48", @@ -48,14 +47,14 @@ "@pierre/diffs": "1.1.0-beta.18", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", - "diff": "8.0.2", - "dompurify": "3.3.1", + "diff": "8.0.4", + "dompurify": "3.3.3", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.48", "ai": "6.0.168", "cross-spawn": "7.0.6", - "hono": "4.10.7", + "hono": "4.12.12", "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", @@ -65,18 +64,18 @@ "@playwright/test": "1.59.1", "semver": "7.7.4", "typescript": "5.8.2", - "@typescript/native-preview": "7.0.0-dev.20251207.1", + "@typescript/native-preview": "7.0.0-dev.20260316.1", "zod": "4.1.8", "remeda": "2.26.0", "shiki": "3.20.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", "virtua": "0.42.3", - "vite": "7.1.4", + "vite": "7.3.2", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", - "solid-js": "1.9.10", + "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" } @@ -93,19 +92,22 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", - "turbo": "2.8.13" + "turbo": "2.8.13", + "@changesets/changelog-github": "^0.5.1", + "@changesets/cli": "^2.27.10" }, "dependencies": { - "@aws-sdk/client-s3": "3.933.0", - "@opencode-ai/plugin": "workspace:*", + "@aws-sdk/client-s3": "3.1025.0", + "@kilocode/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", - "@opencode-ai/sdk": "workspace:*", + "@kilocode/sdk": "workspace:*", "heap-snapshot-toolkit": "1.1.3", - "typescript": "catalog:" + "typescript": "catalog:", + "@morphllm/morphsdk": "0.2.166" }, "repository": { "type": "git", - "url": "https://github.com/anomalyco/opencode" + "url": "https://github.com/Kilo-Org/kilocode" }, "license": "MIT", "prettier": { @@ -124,10 +126,25 @@ ], "overrides": { "@types/bun": "catalog:", - "@types/node": "catalog:" + "@types/node": "catalog:", + "effect": "catalog:", + "@effect/platform-node-shared": "4.0.0-beta.46", + "path-to-regexp": ">=8.4.0", + "picomatch": ">=2.3.2", + "defu": "6.1.6", + "lodash": "4.18.1", + "@xmldom/xmldom": ">=0.8.12", + "smol-toml": ">=1.6.1", + "fastify": ">=5.8.3", + "diff": "8.0.4", + "dompurify": "3.3.3", + "happy-dom": ">=20.8.9" }, "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" - } + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" + }, + "version": "7.2.20", + "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 7d21a9f95b3..0085e686d09 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.17", + "version": "7.2.20", "description": "", "type": "module", "exports": { @@ -40,7 +40,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/sdk": "workspace:*", + "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/shared": "workspace:*", "@shikijs/transformers": "3.9.2", @@ -72,6 +72,9 @@ "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:", - "zod": "catalog:" - } + "zod": "catalog:", + "@kilocode/kilo-ui": "workspace:*", + "@kilocode/kilo-i18n": "workspace:*" + }, + "peerDependencies": {} } diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index e9fb1cfe4ed..0a1e56bb4fa 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -31,8 +31,8 @@ export default defineConfig({ reuseExistingServer: reuse, timeout: 120_000, env: { - VITE_OPENCODE_SERVER_HOST: serverHost, - VITE_OPENCODE_SERVER_PORT: serverPort, + VITE_KILO_SERVER_HOST: serverHost, + VITE_KILO_SERVER_PORT: serverPort, }, }, use: { diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index e305743799a..1ed4786b988 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53b66fb451d..6f38651e6d5 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -183,7 +183,7 @@ export function DialogCustomProvider(props: Props) {

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} {language.t("provider.custom.description.suffix")} diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 710618c3016..4cdcca33ac1 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" 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 type { TextPart as SDKTextPart } from "@kilocode/sdk/v2/client" import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 211ce05ef06..f4148dfe4ed 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,7 +17,7 @@ import { type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@kilocode/sdk/v2" const MAX_DEPTH = 128 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 c268af35eef..9a62d0b71d1 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,5 +1,5 @@ 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 AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@kilocode/sdk/v2/client" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index cf99497232d..e1b959e8dd7 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -63,8 +63,8 @@ beforeAll(async () => { useParams: () => params, })) - mock.module("@opencode-ai/sdk/v2/client", () => ({ - createOpencodeClient: (input: { directory: string }) => { + mock.module("@kilocode/sdk/v2/client", () => ({ + createKiloClient: (input: { directory: string }) => { createdClients.push(input.directory) return clientFor(input.directory) }, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 6805f619c19..fdd2889efa3 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,4 +1,4 @@ -import type { Message, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Session } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/shared/util/encode" import { Binary } from "@opencode-ai/shared/util/binary" diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts index f38aecb55da..36c43843453 100644 --- a/packages/app/src/components/session/session-context-breakdown.test.ts +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@kilocode/sdk/v2/client" import { estimateSessionContextBreakdown } from "./session-context-breakdown" const user = (id: string) => { diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts index e263b2957b3..1b11fadcceb 100644 --- a/packages/app/src/components/session/session-context-breakdown.ts +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -1,4 +1,4 @@ -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@kilocode/sdk/v2/client" export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 0e109a71bda..be1bbca2585 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@kilocode/sdk/v2/client" import { getSessionContextMetrics } from "./session-context-metrics" const assistant = ( diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 0789b05f173..4bdcff52ebb 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message } from "@kilocode/sdk/v2/client" type Provider = { id: string diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index abf4c933462..2fa44f1c7e1 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/ui/file" import { Markdown } from "@opencode-ai/ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, UserMessage } from "@kilocode/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSessionLayout } from "@/pages/session/session-layout" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 021e5be67e3..d61335d9f0d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -153,7 +153,7 @@ export function SessionHeader() { }) const hotkey = createMemo(() => command.keybind("file.open")) const os = createMemo(() => detectOS(platform)) - const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta" + const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_KILO_CHANNEL === "beta" const search = createMemo(() => !isDesktopBeta || settings.general.showSearch()) const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree()) const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal()) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c380fb69b3c..c8eac6e63fc 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -381,7 +381,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > @@ -680,7 +680,7 @@ export const SettingsGeneral: Component = () => { }} - + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 409fcbeff60..eb9bd3ebf1b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -80,7 +80,7 @@ export function Titlebar() { const canBack = createMemo(() => history.index > 0) const canForward = createMemo(() => history.index < history.stack.length - 1) const hasProjects = createMemo(() => layout.projects.list().length > 0) - const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation()) + const nav = createMemo(() => import.meta.env.VITE_KILO_CHANNEL !== "beta" || settings.general.showNavigation()) const back = () => { const next = backPath(history) @@ -288,9 +288,9 @@ export function Titlebar() {

- {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( + {["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && (
- {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} + {import.meta.env.VITE_KILO_CHANNEL.toUpperCase()}
)}
diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts index 4b724068834..98de196ab6f 100644 --- a/packages/app/src/context/file/content-cache.ts +++ b/packages/app/src/context/file/content-cache.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "@kilocode/sdk/v2" const MAX_FILE_CONTENT_ENTRIES = 40 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index a86051d286e..dc4e58f7a25 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -1,5 +1,5 @@ import { createStore, produce, reconcile } from "solid-js/store" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@kilocode/sdk/v2" type DirectoryState = { expanded: boolean diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts index 7ce8a37c25e..ee1cc49f311 100644 --- a/packages/app/src/context/file/types.ts +++ b/packages/app/src/context/file/types.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "@kilocode/sdk/v2" export type FileSelection = { startLine: number diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791..57bc68edc9b 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,4 +1,4 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@kilocode/sdk/v2" type WatcherEvent = { type: string diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a0e..5c81174426f 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -1,4 +1,4 @@ -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@kilocode/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 1a672639b58..b7edea70cd1 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1,12 +1,12 @@ import type { Config, - OpencodeClient, + KiloClient, Path, Project, ProviderAuthResponse, ProviderListResponse, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" @@ -51,7 +51,7 @@ function createGlobalSync() { const owner = getOwner() if (!owner) throw new Error("GlobalSync must be created within owner") - const sdkCache = new Map() + const sdkCache = new Map() const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 17fe726f909..86386fd59a3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,6 +1,6 @@ import type { Config, - OpencodeClient, + KiloClient, Path, PermissionRequest, Project, @@ -9,7 +9,7 @@ import type { QuestionRequest, Session, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" import { retry } from "@opencode-ai/shared/util/retry" @@ -68,7 +68,7 @@ function runAll(list: Array<() => Promise>) { } export async function bootstrapGlobal(input: { - globalSDK: OpencodeClient + globalSDK: KiloClient requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string @@ -164,7 +164,7 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - sdk: OpencodeClient + sdk: KiloClient }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) @@ -188,7 +188,7 @@ export const loadAgentsQuery = (directory: string | null) => export async function bootstrapDirectory(input: { directory: string - sdk: OpencodeClient + sdk: KiloClient store: Store setStore: SetStoreFunction vcsCache: VcsCache diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 3fe67e4fbe6..b94b03fbc35 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { VcsInfo } from "@kilocode/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788e6..f66e7ede312 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@kilocode/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 11a0cf83fd1..d628cad723d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -10,7 +10,7 @@ import type { SessionStatus, SnapshotFileDiff, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 472ac219e9c..471c632d802 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -7,7 +7,7 @@ import type { SessionStatus, SnapshotFileDiff, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f4d81062b1..b578997307c 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -6,7 +6,7 @@ import type { SessionStatus, SnapshotFileDiff, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" export const SESSION_CACHE_LIMIT = 40 diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts index be12c074b5d..da9ef3498f5 100644 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client" import { trimSessions } from "./session-trim" const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts index 800ba74a684..8aa31400217 100644 --- a/packages/app/src/context/global-sync/session-trim.ts +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -1,4 +1,4 @@ -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client" import { cmp } from "./utils" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5eeb..a1a51842684 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -15,7 +15,7 @@ import type { SnapshotFileDiff, Todo, VcsInfo, -} from "@opencode-ai/sdk/v2/client" +} from "@kilocode/sdk/v2/client" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 6d44ac9a892..24f1547c27f 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Agent } from "@opencode-ai/sdk/v2/client" +import type { Agent } from "@kilocode/sdk/v2/client" import { normalizeAgentList } from "./utils" const agent = (name = "build") => diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index cac58f3174e..e6fd6f64ba2 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,4 +1,4 @@ -import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Agent, Project, ProviderListResponse } from "@kilocode/sdk/v2/client" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index 058f7cc4b6c..11ee613e643 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -7,7 +7,7 @@ import { useSettings } from "@/context/settings" import { persisted } from "@/utils/persist" import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" -const CHANGELOG_URL = "https://opencode.ai/changelog.json" +const CHANGELOG_URL = "https://kilo.ai/changelog.json" type Store = { version?: string diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 74ea2853103..192d2499900 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -6,7 +6,7 @@ import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" import { usePlatform } from "./platform" -import { Project } from "@opencode-ai/sdk/v2" +import { Project } from "@kilocode/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { decode64 } from "@/utils/base64" import { same } from "@/utils/same" diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 251b67b06ce..9c30ed269d6 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -10,7 +10,7 @@ import { useSettings } from "@/context/settings" 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 { EventSessionError } from "@kilocode/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 2f8ca6265e6..cb927bbd317 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client" import { base64Encode } from "@opencode-ai/shared/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 672f84f82a6..6f890da7c96 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@kilocode/sdk/v2/client" import { Persist, persisted } from "@/utils/persist" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index bc97ea13acc..2eaf4627c04 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,4 +1,4 @@ -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@kilocode/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts index 94324f8a060..3c1b8f4644d 100644 --- a/packages/app/src/context/sync-optimistic.test.ts +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@kilocode/sdk/v2/client" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" type Text = Extract diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 29b7fe68c51..3fb37e4b26a 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -11,7 +11,7 @@ import { } from "./global-sync/session-prefetch" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@kilocode/sdk/v2/client" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { diffs as list, message as clean } from "@/utils/diffs" diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d..84b2b799dad 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -100,7 +100,7 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { const getCurrentUrl = () => { if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + return `http://${import.meta.env.VITE_KILO_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_KILO_SERVER_PORT ?? "4096"}` return location.origin } diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 9b03d336f9d..1197383a3cd 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,7 +1,7 @@ interface ImportMetaEnv { - readonly VITE_OPENCODE_SERVER_HOST: string - readonly VITE_OPENCODE_SERVER_PORT: string - readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + readonly VITE_KILO_SERVER_HOST: string + readonly VITE_KILO_SERVER_PORT: string + readonly VITE_KILO_CHANNEL?: "dev" | "beta" | "prod" } interface ImportMeta { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 6c3f3bb55ef..6b59d4ed942 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "إدارة النماذج", "dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.", "dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}", - "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode", + "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من Kilo", "dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين", "dialog.provider.viewAll": "عرض المزيد من الموفرين", "provider.connect.title": "اتصال {{provider}}", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "في انتظار التفويض...", "provider.connect.status.failed": "فشل التفويض: {{error}}", "provider.connect.apiKey.description": - "أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", + "أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", "provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}", "provider.connect.apiKey.placeholder": "مفتاح API", "provider.connect.apiKey.required": "مفتاح API مطلوب", @@ -130,7 +130,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "قم بزيارة ", "provider.connect.oauth.code.visit.link": "هذا الرابط", "provider.connect.oauth.code.visit.suffix": - " للحصول على رمز التفويض الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", + " للحصول على رمز التفويض الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", "provider.connect.oauth.code.label": "رمز تفويض {{method}}", "provider.connect.oauth.code.placeholder": "رمز التفويض", "provider.connect.oauth.code.required": "رمز التفويض مطلوب", @@ -138,7 +138,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "قم بزيارة ", "provider.connect.oauth.auto.visit.link": "هذا الرابط", "provider.connect.oauth.auto.visit.suffix": - " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", + " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", "provider.connect.oauth.auto.confirmationCode": "رمز التأكيد", "provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", @@ -281,7 +281,7 @@ export const dict = { "dialog.directory.search.placeholder": "البحث في المجلدات", "dialog.directory.empty": "لم يتم العثور على مجلدات", "dialog.server.title": "الخوادم", - "dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.", + "dialog.server.description": "تبديل خادم Kilo الذي يتصل به هذا التطبيق.", "dialog.server.search.placeholder": "البحث في الخوادم", "dialog.server.empty": "لا توجد خوادم بعد", "dialog.server.add.title": "إضافة خادم", @@ -395,7 +395,7 @@ export const dict = { "toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة", "toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}", "toast.update.title": "تحديث متاح", - "toast.update.description": "نسخة جديدة من OpenCode ({{version}}) متاحة الآن للتثبيت.", + "toast.update.description": "نسخة جديدة من Kilo ({{version}}) متاحة الآن للتثبيت.", "toast.update.action.installRestart": "تثبيت وإعادة تشغيل", "toast.update.action.notYet": "ليس الآن", "error.page.title": "حدث خطأ ما", @@ -405,7 +405,7 @@ export const dict = { "error.page.action.checking": "جارٍ التحقق...", "error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.updateTo": "تحديث إلى {{version}}", - "error.page.report.prefix": "يرجى الإبلاغ عن هذا الخطأ لفريق OpenCode", + "error.page.report.prefix": "يرجى الإبلاغ عن هذا الخطأ لفريق Kilo", "error.page.report.discord": "على Discord", "error.page.version": "الإصدار: {{version}}", "error.dev.rootNotFound": @@ -421,7 +421,7 @@ export const dict = { "error.chain.didYouMean": "هل كنت تعني: {{suggestions}}", "error.chain.modelNotFound": "النموذج غير موجود: {{provider}}/{{model}}", "error.chain.checkConfig": "تحقق من أسماء الموفر/النموذج في التكوين (opencode.json)", - "error.chain.mcpFailed": 'فشل خادم MCP "{{name}}". لاحظ أن OpenCode لا يدعم مصادقة MCP بعد.', + "error.chain.mcpFailed": 'فشل خادم MCP "{{name}}". لاحظ أن Kilo لا يدعم مصادقة MCP بعد.', "error.chain.providerAuthFailed": "فشلت مصادقة الموفر ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'فشل تهيئة الموفر "{{provider}}". تحقق من بيانات الاعتماد والتكوين.', "error.chain.configJsonInvalid": "ملف التكوين في {{path}} ليس JSON(C) صالحًا", @@ -537,19 +537,19 @@ export const dict = { "sidebar.workspaces.enable": "تمكين مساحات العمل", "sidebar.workspaces.disable": "تعطيل مساحات العمل", "sidebar.gettingStarted.title": "البدء", - "sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.", + "sidebar.gettingStarted.line1": "يتضمن Kilo نماذج مجانية حتى تتمكن من البدء فورًا.", "sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.", "sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.viewAllSessions": "عرض جميع الجلسات", "sidebar.project.clearNotifications": "مسح الإشعارات", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "سطح المكتب", "settings.section.server": "الخادم", "settings.tab.general": "عام", "settings.tab.shortcuts": "اختصارات", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "تكامل WSL", - "settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.", + "settings.desktop.wsl.description": "تشغيل خادم Kilo داخل WSL على Windows.", "settings.general.section.appearance": "المظهر", "settings.general.section.notifications": "إشعارات النظام", "settings.general.section.updates": "التحديثات", @@ -557,13 +557,13 @@ export const dict = { "settings.general.section.feed": "الخلاصة", "settings.general.section.display": "شاشة العرض", "settings.general.row.language.title": "اللغة", - "settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode", + "settings.general.row.language.description": "تغيير لغة العرض لـ Kilo", "settings.general.row.appearance.title": "المظهر", - "settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك", + "settings.general.row.appearance.description": "تخصيص كيفية ظهور Kilo على جهازك", "settings.general.row.colorScheme.title": "مخطط الألوان", - "settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن", + "settings.general.row.colorScheme.description": "اختر ما إذا كان Kilo يتبع سمة النظام أو الفاتح أو الداكن", "settings.general.row.theme.title": "السمة", - "settings.general.row.theme.description": "تخصيص سمة OpenCode.", + "settings.general.row.theme.description": "تخصيص سمة Kilo.", "settings.general.row.font.title": "خط الكود", "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات", "settings.general.row.uiFont.title": "خط الواجهة", @@ -587,13 +587,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "ملاحظات الإصدار", "settings.general.row.releaseNotes.description": 'عرض نوافذ "ما الجديد" المنبثقة بعد التحديثات', "settings.updates.row.startup.title": "التحقق من التحديثات عند بدء التشغيل", - "settings.updates.row.startup.description": "التحقق تلقائيًا من التحديثات عند تشغيل OpenCode", + "settings.updates.row.startup.description": "التحقق تلقائيًا من التحديثات عند تشغيل Kilo", "settings.updates.row.check.title": "التحقق من التحديثات", "settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة", "settings.updates.action.checkNow": "تحقق الآن", "settings.updates.action.checking": "جارٍ التحقق...", "settings.updates.toast.latest.title": "أنت على آخر إصدار", - "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.", + "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من Kilo.", "sound.option.none": "بلا", "sound.option.alert01": "تنبيه 01", "sound.option.alert02": "تنبيه 02", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 63880462a46..217a017e606 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "Gerenciar modelos", "dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.", "dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}", - "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode", + "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo Kilo", "dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares", "dialog.provider.viewAll": "Ver mais provedores", "provider.connect.title": "Conectar {{provider}}", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "Aguardando autorização...", "provider.connect.status.failed": "Autorização falhou: {{error}}", "provider.connect.apiKey.description": - "Digite sua chave de API do {{provider}} para conectar sua conta e usar modelos do {{provider}} no OpenCode.", + "Digite sua chave de API do {{provider}} para conectar sua conta e usar modelos do {{provider}} no Kilo.", "provider.connect.apiKey.label": "Chave de API do {{provider}}", "provider.connect.apiKey.placeholder": "Chave de API", "provider.connect.apiKey.required": "A chave de API é obrigatória", @@ -130,7 +130,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Visite ", "provider.connect.oauth.code.visit.link": "este link", "provider.connect.oauth.code.visit.suffix": - " para obter seu código de autorização e conectar sua conta para usar modelos do {{provider}} no OpenCode.", + " para obter seu código de autorização e conectar sua conta para usar modelos do {{provider}} no Kilo.", "provider.connect.oauth.code.label": "Código de autorização {{method}}", "provider.connect.oauth.code.placeholder": "Código de autorização", "provider.connect.oauth.code.required": "O código de autorização é obrigatório", @@ -138,7 +138,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Visite ", "provider.connect.oauth.auto.visit.link": "este link", "provider.connect.oauth.auto.visit.suffix": - " e digite o código abaixo para conectar sua conta e usar modelos do {{provider}} no OpenCode.", + " e digite o código abaixo para conectar sua conta e usar modelos do {{provider}} no Kilo.", "provider.connect.oauth.auto.confirmationCode": "Código de confirmação", "provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.", @@ -281,7 +281,7 @@ export const dict = { "dialog.directory.search.placeholder": "Buscar pastas", "dialog.directory.empty": "Nenhuma pasta encontrada", "dialog.server.title": "Servidores", - "dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.", + "dialog.server.description": "Trocar para qual servidor Kilo este aplicativo se conecta.", "dialog.server.search.placeholder": "Buscar servidores", "dialog.server.empty": "Nenhum servidor ainda", "dialog.server.add.title": "Adicionar um servidor", @@ -396,7 +396,7 @@ export const dict = { "toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão", "toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}", "toast.update.title": "Atualização disponível", - "toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.", + "toast.update.description": "Uma nova versão do Kilo ({{version}}) está disponível para instalação.", "toast.update.action.installRestart": "Instalar e reiniciar", "toast.update.action.notYet": "Agora não", "error.page.title": "Algo deu errado", @@ -406,7 +406,7 @@ export const dict = { "error.page.action.checking": "Verificando...", "error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.updateTo": "Atualizar para {{version}}", - "error.page.report.prefix": "Por favor, reporte este erro para a equipe do OpenCode", + "error.page.report.prefix": "Por favor, reporte este erro para a equipe do Kilo", "error.page.report.discord": "no Discord", "error.page.version": "Versão: {{version}}", "error.dev.rootNotFound": @@ -422,7 +422,7 @@ export const dict = { "error.chain.didYouMean": "Você quis dizer: {{suggestions}}", "error.chain.modelNotFound": "Modelo não encontrado: {{provider}}/{{model}}", "error.chain.checkConfig": "Verifique os nomes de provedor/modelo na sua configuração (opencode.json)", - "error.chain.mcpFailed": 'Servidor MCP "{{name}}" falhou. Nota: OpenCode ainda não suporta autenticação MCP.', + "error.chain.mcpFailed": 'Servidor MCP "{{name}}" falhou. Nota: Kilo ainda não suporta autenticação MCP.', "error.chain.providerAuthFailed": "Autenticação do provedor falhou ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Falha ao inicializar provedor "{{provider}}". Verifique credenciais e configuração.', @@ -544,19 +544,19 @@ export const dict = { "sidebar.workspaces.enable": "Habilitar espaços de trabalho", "sidebar.workspaces.disable": "Desabilitar espaços de trabalho", "sidebar.gettingStarted.title": "Começando", - "sidebar.gettingStarted.line1": "OpenCode inclui modelos gratuitos para você começar imediatamente.", + "sidebar.gettingStarted.line1": "Kilo inclui modelos gratuitos para você começar imediatamente.", "sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessões recentes", "sidebar.project.viewAllSessions": "Ver todas as sessões", "sidebar.project.clearNotifications": "Limpar notificações", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Servidor", "settings.tab.general": "Geral", "settings.tab.shortcuts": "Atalhos", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", - "settings.desktop.wsl.description": "Executar o servidor OpenCode dentro do WSL no Windows.", + "settings.desktop.wsl.description": "Executar o servidor Kilo dentro do WSL no Windows.", "settings.general.section.appearance": "Aparência", "settings.general.section.notifications": "Notificações do sistema", "settings.general.section.updates": "Atualizações", @@ -564,13 +564,13 @@ export const dict = { "settings.general.section.feed": "Feed", "settings.general.section.display": "Tela", "settings.general.row.language.title": "Idioma", - "settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode", + "settings.general.row.language.description": "Alterar o idioma de exibição do Kilo", "settings.general.row.appearance.title": "Aparência", - "settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo", + "settings.general.row.appearance.description": "Personalize como o Kilo aparece no seu dispositivo", "settings.general.row.colorScheme.title": "Esquema de cores", - "settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro", + "settings.general.row.colorScheme.description": "Escolha se o Kilo segue o tema do sistema, claro ou escuro", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.", + "settings.general.row.theme.description": "Personalize como o Kilo é tematizado.", "settings.general.row.font.title": "Fonte de código", "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais", "settings.general.row.uiFont.title": "Fonte da interface", @@ -595,13 +595,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "Notas da versão", "settings.general.row.releaseNotes.description": 'Mostrar pop-ups de "Novidades" após atualizações', "settings.updates.row.startup.title": "Verificar atualizações ao iniciar", - "settings.updates.row.startup.description": "Verificar atualizações automaticamente quando o OpenCode iniciar", + "settings.updates.row.startup.description": "Verificar atualizações automaticamente quando o Kilo iniciar", "settings.updates.row.check.title": "Verificar atualizações", "settings.updates.row.check.description": "Verificar atualizações manualmente e instalar se houver", "settings.updates.action.checkNow": "Verificar agora", "settings.updates.action.checking": "Verificando...", "settings.updates.toast.latest.title": "Você está atualizado", - "settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.", + "settings.updates.toast.latest.description": "Você está usando a versão mais recente do Kilo.", "sound.option.none": "Nenhum", "sound.option.alert01": "Alerta 01", "sound.option.alert02": "Alerta 02", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 2b589eb35f6..46b40269b00 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.", "dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele", - "dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode", + "dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje Kilo", "dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera", "dialog.provider.viewAll": "Prikaži više provajdera", @@ -128,7 +128,7 @@ export const dict = { "provider.connect.status.waiting": "Čekanje na autorizaciju...", "provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}", "provider.connect.apiKey.description": - "Unesi svoj {{provider}} API ključ da povežeš račun i koristiš {{provider}} modele u OpenCode-u.", + "Unesi svoj {{provider}} API ključ da povežeš račun i koristiš {{provider}} modele u Kilo-u.", "provider.connect.apiKey.label": "{{provider}} API ključ", "provider.connect.apiKey.placeholder": "API ključ", "provider.connect.apiKey.required": "API ključ je obavezan", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Posjeti ", "provider.connect.oauth.code.visit.link": "ovaj link", "provider.connect.oauth.code.visit.suffix": - " da preuzmeš autorizacijski kod i povežeš račun te koristiš {{provider}} modele u OpenCode-u.", + " da preuzmeš autorizacijski kod i povežeš račun te koristiš {{provider}} modele u Kilo-u.", "provider.connect.oauth.code.label": "{{method}} autorizacijski kod", "provider.connect.oauth.code.placeholder": "Autorizacijski kod", "provider.connect.oauth.code.required": "Autorizacijski kod je obavezan", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Posjeti ", "provider.connect.oauth.auto.visit.link": "ovaj link", "provider.connect.oauth.auto.visit.suffix": - " i unesi kod ispod da povežeš račun i koristiš {{provider}} modele u OpenCode-u.", + " i unesi kod ispod da povežeš račun i koristiš {{provider}} modele u Kilo-u.", "provider.connect.oauth.auto.confirmationCode": "Kod za potvrdu", "provider.connect.toast.connected.title": "{{provider}} povezan", "provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.", @@ -308,7 +308,7 @@ export const dict = { "dialog.directory.empty": "Nema pronađenih foldera", "dialog.server.title": "Serveri", - "dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.", + "dialog.server.description": "Promijeni na koji se Kilo server ova aplikacija povezuje.", "dialog.server.search.placeholder": "Pretraži servere", "dialog.server.empty": "Još nema servera", "dialog.server.add.title": "Dodaj server", @@ -441,7 +441,7 @@ export const dict = { "toast.session.listFailed.title": "Neuspjelo učitavanje sesija za {{project}}", "toast.update.title": "Dostupno ažuriranje", - "toast.update.description": "Nova verzija OpenCode-a ({{version}}) je dostupna za instalaciju.", + "toast.update.description": "Nova verzija Kilo-a ({{version}}) je dostupna za instalaciju.", "toast.update.action.installRestart": "Instaliraj i restartuj", "toast.update.action.notYet": "Ne još", @@ -452,7 +452,7 @@ export const dict = { "error.page.action.checking": "Provjera...", "error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.updateTo": "Ažuriraj na {{version}}", - "error.page.report.prefix": "Molimo prijavi ovu grešku OpenCode timu", + "error.page.report.prefix": "Molimo prijavi ovu grešku Kilo timu", "error.page.report.discord": "na Discordu", "error.page.version": "Verzija: {{version}}", @@ -471,7 +471,7 @@ export const dict = { "error.chain.didYouMean": "Da li si mislio: {{suggestions}}", "error.chain.modelNotFound": "Model nije pronađen: {{provider}}/{{model}}", "error.chain.checkConfig": "Provjeri konfiguraciju (opencode.json) provider/model names", - "error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: OpenCode još ne podržava MCP autentifikaciju.', + "error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: Kilo još ne podržava MCP autentifikaciju.', "error.chain.providerAuthFailed": "Autentifikacija provajdera nije uspjela ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Neuspjelo inicijalizovanje provajdera "{{provider}}". Provjeri kredencijale i konfiguraciju.', @@ -605,13 +605,13 @@ export const dict = { "sidebar.workspaces.enable": "Omogući radne prostore", "sidebar.workspaces.disable": "Onemogući radne prostore", "sidebar.gettingStarted.title": "Početak", - "sidebar.gettingStarted.line1": "OpenCode uključuje besplatne modele, tako da možeš odmah početi.", + "sidebar.gettingStarted.line1": "Kilo uključuje besplatne modele, tako da možeš odmah početi.", "sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.", "sidebar.project.recentSessions": "Nedavne sesije", "sidebar.project.viewAllSessions": "Prikaži sve sesije", "sidebar.project.clearNotifications": "Očisti obavijesti", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", @@ -619,7 +619,7 @@ export const dict = { "settings.tab.shortcuts": "Prečice", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integracija", - "settings.desktop.wsl.description": "Pokreni OpenCode server unutar WSL-a na Windowsu.", + "settings.desktop.wsl.description": "Pokreni Kilo server unutar WSL-a na Windowsu.", "settings.general.section.appearance": "Izgled", "settings.general.section.notifications": "Sistemske obavijesti", @@ -629,13 +629,13 @@ export const dict = { "settings.general.section.display": "Prikaz", "settings.general.row.language.title": "Jezik", - "settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u", + "settings.general.row.language.description": "Promijeni jezik prikaza u Kilo-u", "settings.general.row.appearance.title": "Izgled", - "settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju", + "settings.general.row.appearance.description": "Prilagodi kako Kilo izgleda na tvom uređaju", "settings.general.row.colorScheme.title": "Šema boja", - "settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu", + "settings.general.row.colorScheme.description": "Odaberi da li Kilo prati sistemsku, svijetlu ili tamnu temu", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "Prilagodi temu OpenCode-a.", + "settings.general.row.theme.description": "Prilagodi temu Kilo-a.", "settings.general.row.font.title": "Font za kod", "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima", "settings.general.row.uiFont.title": "UI font", @@ -662,13 +662,13 @@ export const dict = { "settings.general.row.releaseNotes.description": 'Prikaži iskačuće prozore "Šta je novo" nakon ažuriranja', "settings.updates.row.startup.title": "Provjeri ažuriranja pri pokretanju", - "settings.updates.row.startup.description": "Automatski provjerava ažuriranja kada se OpenCode pokrene", + "settings.updates.row.startup.description": "Automatski provjerava ažuriranja kada se Kilo pokrene", "settings.updates.row.check.title": "Provjeri ažuriranja", "settings.updates.row.check.description": "Ručno provjeri ažuriranja i instaliraj ako su dostupna", "settings.updates.action.checkNow": "Provjeri sada", "settings.updates.action.checking": "Provjera...", "settings.updates.toast.latest.title": "Sve je ažurno", - "settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.", + "settings.updates.toast.latest.description": "Koristiš najnoviju verziju Kilo-a.", "sound.option.none": "Nijedan", "sound.option.alert01": "Upozorenje 01", "sound.option.alert02": "Upozorenje 02", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index b096d87b4b7..66b6ead5799 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.", "dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller", - "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode", + "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af Kilo", "dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere", "dialog.provider.viewAll": "Vis flere udbydere", @@ -128,7 +128,7 @@ export const dict = { "provider.connect.status.waiting": "Venter på godkendelse...", "provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}", "provider.connect.apiKey.description": - "Indtast din {{provider}} API-nøgle for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", + "Indtast din {{provider}} API-nøgle for at forbinde din konto og bruge {{provider}} modeller i Kilo.", "provider.connect.apiKey.label": "{{provider}} API-nøgle", "provider.connect.apiKey.placeholder": "API-nøgle", "provider.connect.apiKey.required": "API-nøgle er påkrævet", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Besøg ", "provider.connect.oauth.code.visit.link": "dette link", "provider.connect.oauth.code.visit.suffix": - " for at hente din godkendelseskode for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", + " for at hente din godkendelseskode for at forbinde din konto og bruge {{provider}} modeller i Kilo.", "provider.connect.oauth.code.label": "{{method}} godkendelseskode", "provider.connect.oauth.code.placeholder": "Godkendelseskode", "provider.connect.oauth.code.required": "Godkendelseskode er påkrævet", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Besøg ", "provider.connect.oauth.auto.visit.link": "dette link", "provider.connect.oauth.auto.visit.suffix": - " og indtast koden nedenfor for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", + " og indtast koden nedenfor for at forbinde din konto og bruge {{provider}} modeller i Kilo.", "provider.connect.oauth.auto.confirmationCode": "Bekræftelseskode", "provider.connect.toast.connected.title": "{{provider}} forbundet", "provider.connect.toast.connected.description": "{{provider}} modeller er nu tilgængelige.", @@ -306,7 +306,7 @@ export const dict = { "dialog.directory.empty": "Ingen mapper fundet", "dialog.server.title": "Servere", - "dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.", + "dialog.server.description": "Skift hvilken Kilo-server denne app forbinder til.", "dialog.server.search.placeholder": "Søg servere", "dialog.server.empty": "Ingen servere endnu", "dialog.server.add.title": "Tilføj en server", @@ -438,7 +438,7 @@ export const dict = { "toast.session.listFailed.title": "Kunne ikke indlæse sessioner for {{project}}", "toast.update.title": "Opdatering tilgængelig", - "toast.update.description": "En ny version af OpenCode ({{version}}) er nu tilgængelig til installation.", + "toast.update.description": "En ny version af Kilo ({{version}}) er nu tilgængelig til installation.", "toast.update.action.installRestart": "Installer og genstart", "toast.update.action.notYet": "Ikke endnu", @@ -449,7 +449,7 @@ export const dict = { "error.page.action.checking": "Tjekker...", "error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.updateTo": "Opdater til {{version}}", - "error.page.report.prefix": "Rapporter venligst denne fejl til OpenCode-teamet", + "error.page.report.prefix": "Rapporter venligst denne fejl til Kilo-teamet", "error.page.report.discord": "på Discord", "error.page.version": "Version: {{version}}", @@ -468,7 +468,7 @@ export const dict = { "error.chain.didYouMean": "Mente du: {{suggestions}}", "error.chain.modelNotFound": "Model ikke fundet: {{provider}}/{{model}}", "error.chain.checkConfig": "Tjek dine konfigurations (opencode.json) udbyder/modelnavne", - "error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, OpenCode understøtter ikke MCP-godkendelse endnu.', + "error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, Kilo understøtter ikke MCP-godkendelse endnu.', "error.chain.providerAuthFailed": "Udbydergodkendelse mislykkedes ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Kunne ikke initialisere udbyder "{{provider}}". Tjek legitimationsoplysninger og konfiguration.', @@ -601,20 +601,20 @@ export const dict = { "sidebar.workspaces.enable": "Aktiver arbejdsområder", "sidebar.workspaces.disable": "Deaktiver arbejdsområder", "sidebar.gettingStarted.title": "Kom i gang", - "sidebar.gettingStarted.line1": "OpenCode inkluderer gratis modeller så du kan starte med det samme.", + "sidebar.gettingStarted.line1": "Kilo inkluderer gratis modeller så du kan starte med det samme.", "sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.project.recentSessions": "Seneste sessioner", "sidebar.project.viewAllSessions": "Vis alle sessioner", "sidebar.project.clearNotifications": "Ryd notifikationer", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", "settings.tab.general": "Generelt", "settings.tab.shortcuts": "Genveje", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", - "settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.", + "settings.desktop.wsl.description": "Kør Kilo-serveren inde i WSL på Windows.", "settings.general.section.appearance": "Udseende", "settings.general.section.notifications": "Systemmeddelelser", @@ -624,13 +624,13 @@ export const dict = { "settings.general.section.display": "Skærm", "settings.general.row.language.title": "Sprog", - "settings.general.row.language.description": "Ændr visningssproget for OpenCode", + "settings.general.row.language.description": "Ændr visningssproget for Kilo", "settings.general.row.appearance.title": "Udseende", - "settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed", + "settings.general.row.appearance.description": "Tilpas hvordan Kilo ser ud på din enhed", "settings.general.row.colorScheme.title": "Farveskema", - "settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema", + "settings.general.row.colorScheme.description": "Vælg om Kilo følger systemets, lyst eller mørkt tema", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.", + "settings.general.row.theme.description": "Tilpas hvordan Kilo er temabestemt.", "settings.general.row.font.title": "Kode-skrifttype", "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler", "settings.general.row.uiFont.title": "UI-skrifttype", @@ -656,13 +656,13 @@ export const dict = { "settings.general.row.releaseNotes.description": 'Vis "Hvad er nyt"-popups efter opdateringer', "settings.updates.row.startup.title": "Tjek for opdateringer ved opstart", - "settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når OpenCode starter", + "settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når Kilo starter", "settings.updates.row.check.title": "Tjek for opdateringer", "settings.updates.row.check.description": "Tjek manuelt for opdateringer og installer, hvis tilgængelig", "settings.updates.action.checkNow": "Tjek nu", "settings.updates.action.checking": "Tjekker...", "settings.updates.toast.latest.title": "Du er opdateret", - "settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.", + "settings.updates.toast.latest.description": "Du kører den nyeste version af Kilo.", "sound.option.none": "Ingen", "sound.option.alert01": "Alarm 01", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 6dc0b049724..694addfaeec 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -109,7 +109,7 @@ export const dict = { "dialog.model.manage": "Modelle verwalten", "dialog.model.manage.description": "Anpassen, welche Modelle in der Modellauswahl erscheinen.", "dialog.model.manage.provider.toggle": "Alle {{provider}}-Modelle umschalten", - "dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von OpenCode", + "dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von Kilo", "dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen", "dialog.provider.viewAll": "Mehr Anbieter anzeigen", "provider.connect.title": "{{provider}} verbinden", @@ -120,7 +120,7 @@ export const dict = { "provider.connect.status.waiting": "Warten auf Autorisierung...", "provider.connect.status.failed": "Autorisierung fehlgeschlagen: {{error}}", "provider.connect.apiKey.description": - "Geben Sie Ihren {{provider}} API-Schlüssel ein, um Ihr Konto zu verbinden und {{provider}} Modelle in OpenCode zu nutzen.", + "Geben Sie Ihren {{provider}} API-Schlüssel ein, um Ihr Konto zu verbinden und {{provider}} Modelle in Kilo zu nutzen.", "provider.connect.apiKey.label": "{{provider}} API-Schlüssel", "provider.connect.apiKey.placeholder": "API-Schlüssel", "provider.connect.apiKey.required": "API-Schlüssel ist erforderlich", @@ -134,7 +134,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Besuchen Sie ", "provider.connect.oauth.code.visit.link": "diesen Link", "provider.connect.oauth.code.visit.suffix": - ", um Ihren Autorisierungscode zu erhalten, Ihr Konto zu verbinden und {{provider}} Modelle in OpenCode zu nutzen.", + ", um Ihren Autorisierungscode zu erhalten, Ihr Konto zu verbinden und {{provider}} Modelle in Kilo zu nutzen.", "provider.connect.oauth.code.label": "{{method}} Autorisierungscode", "provider.connect.oauth.code.placeholder": "Autorisierungscode", "provider.connect.oauth.code.required": "Autorisierungscode ist erforderlich", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Besuchen Sie ", "provider.connect.oauth.auto.visit.link": "diesen Link", "provider.connect.oauth.auto.visit.suffix": - " und geben Sie den untenstehenden Code ein, um Ihr Konto zu verbinden und {{provider}} Modelle in OpenCode zu nutzen.", + " und geben Sie den untenstehenden Code ein, um Ihr Konto zu verbinden und {{provider}} Modelle in Kilo zu nutzen.", "provider.connect.oauth.auto.confirmationCode": "Bestätigungscode", "provider.connect.toast.connected.title": "{{provider}} verbunden", "provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.", @@ -287,7 +287,7 @@ export const dict = { "dialog.directory.search.placeholder": "Ordner durchsuchen", "dialog.directory.empty": "Keine Ordner gefunden", "dialog.server.title": "Server", - "dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.", + "dialog.server.description": "Wechseln Sie den Kilo-Server, mit dem sich diese App verbindet.", "dialog.server.search.placeholder": "Server durchsuchen", "dialog.server.empty": "Noch keine Server", "dialog.server.add.title": "Server hinzufügen", @@ -403,7 +403,7 @@ export const dict = { "toast.session.unshare.failed.description": "Beim Aufheben des Teilens ist ein Fehler aufgetreten", "toast.session.listFailed.title": "Sitzungen für {{project}} konnten nicht geladen werden", "toast.update.title": "Update verfügbar", - "toast.update.description": "Eine neue Version von OpenCode ({{version}}) ist zur Installation verfügbar.", + "toast.update.description": "Eine neue Version von Kilo ({{version}}) ist zur Installation verfügbar.", "toast.update.action.installRestart": "Installieren und neu starten", "toast.update.action.notYet": "Noch nicht", "error.page.title": "Etwas ist schiefgelaufen", @@ -413,7 +413,7 @@ export const dict = { "error.page.action.checking": "Prüfen...", "error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.updateTo": "Auf {{version}} aktualisieren", - "error.page.report.prefix": "Bitte melden Sie diesen Fehler dem OpenCode-Team", + "error.page.report.prefix": "Bitte melden Sie diesen Fehler dem Kilo-Team", "error.page.report.discord": "auf Discord", "error.page.version": "Version: {{version}}", "error.dev.rootNotFound": @@ -430,7 +430,7 @@ export const dict = { "error.chain.modelNotFound": "Modell nicht gefunden: {{provider}}/{{model}}", "error.chain.checkConfig": "Überprüfen Sie Ihre Konfiguration (opencode.json) auf Anbieter-/Modellnamen", "error.chain.mcpFailed": - 'MCP-Server "{{name}}" fehlgeschlagen. Hinweis: OpenCode unterstützt noch keine MCP-Authentifizierung.', + 'MCP-Server "{{name}}" fehlgeschlagen. Hinweis: Kilo unterstützt noch keine MCP-Authentifizierung.', "error.chain.providerAuthFailed": "Anbieter-Authentifizierung fehlgeschlagen ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Anbieter "{{provider}}" konnte nicht initialisiert werden. Überprüfen Sie Anmeldeinformationen und Konfiguration.', @@ -552,20 +552,20 @@ export const dict = { "sidebar.workspaces.enable": "Arbeitsbereiche aktivieren", "sidebar.workspaces.disable": "Arbeitsbereiche deaktivieren", "sidebar.gettingStarted.title": "Erste Schritte", - "sidebar.gettingStarted.line1": "OpenCode enthält kostenlose Modelle, damit Sie sofort loslegen können.", + "sidebar.gettingStarted.line1": "Kilo enthält kostenlose Modelle, damit Sie sofort loslegen können.", "sidebar.gettingStarted.line2": "Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.", "sidebar.project.recentSessions": "Letzte Sitzungen", "sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen", "sidebar.project.clearNotifications": "Benachrichtigungen löschen", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", "settings.tab.general": "Allgemein", "settings.tab.shortcuts": "Tastenkombinationen", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL-Integration", - "settings.desktop.wsl.description": "OpenCode-Server innerhalb von WSL unter Windows ausführen.", + "settings.desktop.wsl.description": "Kilo-Server innerhalb von WSL unter Windows ausführen.", "settings.general.section.appearance": "Erscheinungsbild", "settings.general.section.notifications": "Systembenachrichtigungen", "settings.general.section.updates": "Updates", @@ -573,14 +573,14 @@ export const dict = { "settings.general.section.feed": "Feed", "settings.general.section.display": "Anzeige", "settings.general.row.language.title": "Sprache", - "settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern", + "settings.general.row.language.description": "Die Anzeigesprache für Kilo ändern", "settings.general.row.appearance.title": "Erscheinungsbild", - "settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht", + "settings.general.row.appearance.description": "Anpassen, wie Kilo auf Ihrem Gerät aussieht", "settings.general.row.colorScheme.title": "Farbschema", "settings.general.row.colorScheme.description": - "Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt", + "Wählen Sie, ob Kilo dem System-, hellen oder dunklen Thema folgt", "settings.general.row.theme.title": "Thema", - "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.", + "settings.general.row.theme.description": "Das Thema von Kilo anpassen.", "settings.general.row.font.title": "Code-Schriftart", "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen", "settings.general.row.uiFont.title": "UI-Schriftart", @@ -606,13 +606,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "Versionshinweise", "settings.general.row.releaseNotes.description": '"Neuigkeiten"-Pop-ups nach Updates anzeigen', "settings.updates.row.startup.title": "Beim Start nach Updates suchen", - "settings.updates.row.startup.description": "Beim Start von OpenCode automatisch nach Updates suchen", + "settings.updates.row.startup.description": "Beim Start von Kilo automatisch nach Updates suchen", "settings.updates.row.check.title": "Nach Updates suchen", "settings.updates.row.check.description": "Manuell nach Updates suchen und installieren, wenn verfügbar", "settings.updates.action.checkNow": "Jetzt prüfen", "settings.updates.action.checking": "Wird geprüft...", "settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand", - "settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.", + "settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von Kilo.", "sound.option.none": "Keine", "sound.option.alert01": "Alarm 01", "sound.option.alert02": "Alarm 02", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 2cfb7971488..30b5fce6361 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -117,7 +117,7 @@ export const dict = { "dialog.model.manage.description": "Customize which models appear in the model selector.", "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", - "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", + "dialog.model.unpaid.freeModels.title": "Free models provided by Kilo", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", "dialog.provider.viewAll": "Show more providers", @@ -130,7 +130,7 @@ export const dict = { "provider.connect.status.waiting": "Waiting for authorization...", "provider.connect.status.failed": "Authorization failed: {{error}}", "provider.connect.apiKey.description": - "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", + "Enter your {{provider}} API key to connect your account and use {{provider}} models in Kilo.", "provider.connect.apiKey.label": "{{provider}} API key", "provider.connect.apiKey.placeholder": "API key", "provider.connect.apiKey.required": "API key is required", @@ -144,7 +144,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Visit ", "provider.connect.oauth.code.visit.link": "this link", "provider.connect.oauth.code.visit.suffix": - " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", + " to collect your authorization code to connect your account and use {{provider}} models in Kilo.", "provider.connect.oauth.code.label": "{{method}} authorization code", "provider.connect.oauth.code.placeholder": "Authorization code", "provider.connect.oauth.code.required": "Authorization code is required", @@ -152,7 +152,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Visit ", "provider.connect.oauth.auto.visit.link": "this link", "provider.connect.oauth.auto.visit.suffix": - " and enter the code below to connect your account and use {{provider}} models in OpenCode.", + " and enter the code below to connect your account and use {{provider}} models in Kilo.", "provider.connect.oauth.auto.confirmationCode": "Confirmation code", "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", @@ -316,7 +316,7 @@ export const dict = { "app.server.otherServers": "Other servers", "dialog.server.title": "Servers", - "dialog.server.description": "Switch which OpenCode server this app connects to.", + "dialog.server.description": "Switch which Kilo server this app connects to.", "dialog.server.search.placeholder": "Search servers", "dialog.server.empty": "No servers yet", "dialog.server.add.title": "Add server", @@ -457,7 +457,7 @@ export const dict = { "toast.project.reloadFailed.title": "Failed to reload {{project}}", "toast.update.title": "Update available", - "toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.", + "toast.update.description": "A new version of Kilo ({{version}}) is now available to install.", "toast.update.action.installRestart": "Install and restart", "toast.update.action.notYet": "Not yet", @@ -469,7 +469,7 @@ export const dict = { "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", "error.page.circular": "[Circular]", - "error.page.report.prefix": "Please report this error to the OpenCode team", + "error.page.report.prefix": "Please report this error to the Kilo team", "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", @@ -495,7 +495,7 @@ export const dict = { "error.chain.didYouMean": "Did you mean: {{suggestions}}", "error.chain.modelNotFound": "Model not found: {{provider}}/{{model}}", "error.chain.checkConfig": "Check your config (opencode.json) provider/model names", - "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.', + "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, Kilo does not support MCP authentication yet.', "error.chain.providerAuthFailed": "Provider authentication failed ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Failed to initialize provider "{{provider}}". Check credentials and configuration.', @@ -676,7 +676,7 @@ export const dict = { "sidebar.workspaces.enable": "Enable workspaces", "sidebar.workspaces.disable": "Disable workspaces", "sidebar.gettingStarted.title": "Getting started", - "sidebar.gettingStarted.line1": "OpenCode includes free models so you can start immediately.", + "sidebar.gettingStarted.line1": "Kilo includes free models so you can start immediately.", "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", @@ -708,7 +708,7 @@ export const dict = { "debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", "debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", @@ -716,7 +716,7 @@ export const dict = { "settings.tab.shortcuts": "Shortcuts", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", - "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", + "settings.desktop.wsl.description": "Run the Kilo server inside WSL on Windows.", "settings.general.section.appearance": "Appearance", "settings.general.section.advanced": "Advanced", @@ -727,13 +727,13 @@ export const dict = { "settings.general.section.display": "Display", "settings.general.row.language.title": "Language", - "settings.general.row.language.description": "Change the display language for OpenCode", + "settings.general.row.language.description": "Change the display language for Kilo", "settings.general.row.appearance.title": "Appearance", - "settings.general.row.appearance.description": "Customise how OpenCode looks on your device", + "settings.general.row.appearance.description": "Customise how Kilo looks on your device", "settings.general.row.colorScheme.title": "Color scheme", - "settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme", + "settings.general.row.colorScheme.description": "Choose whether Kilo follows the system, light, or dark theme", "settings.general.row.theme.title": "Theme", - "settings.general.row.theme.description": "Customise how OpenCode is themed.", + "settings.general.row.theme.description": "Customise how Kilo is themed.", "settings.general.row.font.title": "Code Font", "settings.general.row.font.description": "Customise the font used in code blocks and terminals", "settings.general.row.uiFont.title": "UI Font", @@ -770,13 +770,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "Show What's New popups after updates", "settings.updates.row.startup.title": "Check for updates on startup", - "settings.updates.row.startup.description": "Automatically check for updates when OpenCode launches", + "settings.updates.row.startup.description": "Automatically check for updates when Kilo launches", "settings.updates.row.check.title": "Check for updates", "settings.updates.row.check.description": "Manually check for updates and install if available", "settings.updates.action.checkNow": "Check now", "settings.updates.action.checking": "Checking...", "settings.updates.toast.latest.title": "You're up to date", - "settings.updates.toast.latest.description": "You're running the latest version of OpenCode.", + "settings.updates.toast.latest.description": "You're running the latest version of Kilo.", "sound.option.none": "None", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c600232ef61..c00c2ba50c9 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "Personalizar qué modelos aparecen en el selector de modelos.", "dialog.model.manage.provider.toggle": "Alternar todos los modelos de {{provider}}", - "dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por OpenCode", + "dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por Kilo", "dialog.model.unpaid.addMore.title": "Añadir más modelos de proveedores populares", "dialog.provider.viewAll": "Ver más proveedores", @@ -128,7 +128,7 @@ export const dict = { "provider.connect.status.waiting": "Esperando autorización...", "provider.connect.status.failed": "Autorización fallida: {{error}}", "provider.connect.apiKey.description": - "Introduce tu clave API de {{provider}} para conectar tu cuenta y usar modelos de {{provider}} en OpenCode.", + "Introduce tu clave API de {{provider}} para conectar tu cuenta y usar modelos de {{provider}} en Kilo.", "provider.connect.apiKey.label": "Clave API de {{provider}}", "provider.connect.apiKey.placeholder": "Clave API", "provider.connect.apiKey.required": "La clave API es obligatoria", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Visita ", "provider.connect.oauth.code.visit.link": "este enlace", "provider.connect.oauth.code.visit.suffix": - " para obtener tu código de autorización para conectar tu cuenta y usar modelos de {{provider}} en OpenCode.", + " para obtener tu código de autorización para conectar tu cuenta y usar modelos de {{provider}} en Kilo.", "provider.connect.oauth.code.label": "Código de autorización {{method}}", "provider.connect.oauth.code.placeholder": "Código de autorización", "provider.connect.oauth.code.required": "El código de autorización es obligatorio", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Visita ", "provider.connect.oauth.auto.visit.link": "este enlace", "provider.connect.oauth.auto.visit.suffix": - " e introduce el código a continuación para conectar tu cuenta y usar modelos de {{provider}} en OpenCode.", + " e introduce el código a continuación para conectar tu cuenta y usar modelos de {{provider}} en Kilo.", "provider.connect.oauth.auto.confirmationCode": "Código de confirmación", "provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.description": "Los modelos de {{provider}} ahora están disponibles para usar.", @@ -307,7 +307,7 @@ export const dict = { "dialog.directory.empty": "No se encontraron carpetas", "dialog.server.title": "Servidores", - "dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.", + "dialog.server.description": "Cambiar a qué servidor de Kilo se conecta esta app.", "dialog.server.search.placeholder": "Buscar servidores", "dialog.server.empty": "No hay servidores aún", "dialog.server.add.title": "Añadir un servidor", @@ -441,7 +441,7 @@ export const dict = { "toast.session.listFailed.title": "Fallo al cargar sesiones para {{project}}", "toast.update.title": "Actualización disponible", - "toast.update.description": "Una nueva versión de OpenCode ({{version}}) está disponible para instalar.", + "toast.update.description": "Una nueva versión de Kilo ({{version}}) está disponible para instalar.", "toast.update.action.installRestart": "Instalar y reiniciar", "toast.update.action.notYet": "Todavía no", @@ -452,7 +452,7 @@ export const dict = { "error.page.action.checking": "Comprobando...", "error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.updateTo": "Actualizar a {{version}}", - "error.page.report.prefix": "Por favor reporta este error al equipo de OpenCode", + "error.page.report.prefix": "Por favor reporta este error al equipo de Kilo", "error.page.report.discord": "en Discord", "error.page.version": "Versión: {{version}}", @@ -471,7 +471,7 @@ export const dict = { "error.chain.didYouMean": "¿Quisiste decir: {{suggestions}}", "error.chain.modelNotFound": "Modelo no encontrado: {{provider}}/{{model}}", "error.chain.checkConfig": "Comprueba los nombres de proveedor/modelo en tu configuración (opencode.json)", - "error.chain.mcpFailed": 'El servidor MCP "{{name}}" falló. Nota, OpenCode no soporta autenticación MCP todavía.', + "error.chain.mcpFailed": 'El servidor MCP "{{name}}" falló. Nota, Kilo no soporta autenticación MCP todavía.', "error.chain.providerAuthFailed": "Autenticación de proveedor fallida ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Fallo al inicializar proveedor "{{provider}}". Comprueba credenciales y configuración.', @@ -608,13 +608,13 @@ export const dict = { "sidebar.workspaces.enable": "Habilitar espacios de trabajo", "sidebar.workspaces.disable": "Deshabilitar espacios de trabajo", "sidebar.gettingStarted.title": "Empezando", - "sidebar.gettingStarted.line1": "OpenCode incluye modelos gratuitos para que puedas empezar inmediatamente.", + "sidebar.gettingStarted.line1": "Kilo incluye modelos gratuitos para que puedas empezar inmediatamente.", "sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", "sidebar.project.clearNotifications": "Borrar notificaciones", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Escritorio", "settings.section.server": "Servidor", @@ -622,7 +622,7 @@ export const dict = { "settings.tab.shortcuts": "Atajos", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "Integración con WSL", - "settings.desktop.wsl.description": "Ejecutar el servidor OpenCode dentro de WSL en Windows.", + "settings.desktop.wsl.description": "Ejecutar el servidor Kilo dentro de WSL en Windows.", "settings.general.section.appearance": "Apariencia", "settings.general.section.notifications": "Notificaciones del sistema", @@ -632,13 +632,13 @@ export const dict = { "settings.general.section.display": "Pantalla", "settings.general.row.language.title": "Idioma", - "settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode", + "settings.general.row.language.description": "Cambiar el idioma de visualización para Kilo", "settings.general.row.appearance.title": "Apariencia", - "settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo", + "settings.general.row.appearance.description": "Personaliza cómo se ve Kilo en tu dispositivo", "settings.general.row.colorScheme.title": "Esquema de color", - "settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro", + "settings.general.row.colorScheme.description": "Elige si Kilo sigue el tema del sistema, claro u oscuro", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "Personaliza el tema de OpenCode.", + "settings.general.row.theme.description": "Personaliza el tema de Kilo.", "settings.general.row.font.title": "Fuente de código", "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales", "settings.general.row.uiFont.title": "Fuente de la interfaz", @@ -667,13 +667,13 @@ export const dict = { 'Mostrar ventanas emergentes de "Novedades" después de las actualizaciones', "settings.updates.row.startup.title": "Buscar actualizaciones al iniciar", - "settings.updates.row.startup.description": "Buscar actualizaciones automáticamente cuando se inicia OpenCode", + "settings.updates.row.startup.description": "Buscar actualizaciones automáticamente cuando se inicia Kilo", "settings.updates.row.check.title": "Buscar actualizaciones", "settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna", "settings.updates.action.checkNow": "Buscar ahora", "settings.updates.action.checking": "Buscando...", "settings.updates.toast.latest.title": "Estás al día", - "settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.", + "settings.updates.toast.latest.description": "Estás usando la última versión de Kilo.", "sound.option.none": "Ninguno", "sound.option.alert01": "Alerta 01", "sound.option.alert02": "Alerta 02", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index a140c1e3a12..2bd6ea940e6 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "Gérer les modèles", "dialog.model.manage.description": "Personnalisez les modèles qui apparaissent dans le sélecteur.", "dialog.model.manage.provider.toggle": "Basculer tous les modèles {{provider}}", - "dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par OpenCode", + "dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par Kilo", "dialog.model.unpaid.addMore.title": "Ajouter plus de modèles de fournisseurs populaires", "dialog.provider.viewAll": "Voir plus de fournisseurs", "provider.connect.title": "Connecter {{provider}}", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "En attente d'autorisation...", "provider.connect.status.failed": "Échec de l'autorisation : {{error}}", "provider.connect.apiKey.description": - "Entrez votre clé API {{provider}} pour connecter votre compte et utiliser les modèles {{provider}} dans OpenCode.", + "Entrez votre clé API {{provider}} pour connecter votre compte et utiliser les modèles {{provider}} dans Kilo.", "provider.connect.apiKey.label": "Clé API {{provider}}", "provider.connect.apiKey.placeholder": "Clé API", "provider.connect.apiKey.required": "La clé API est requise", @@ -130,7 +130,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Visitez ", "provider.connect.oauth.code.visit.link": "ce lien", "provider.connect.oauth.code.visit.suffix": - " pour récupérer votre code d'autorisation afin de connecter votre compte et utiliser les modèles {{provider}} dans OpenCode.", + " pour récupérer votre code d'autorisation afin de connecter votre compte et utiliser les modèles {{provider}} dans Kilo.", "provider.connect.oauth.code.label": "Code d'autorisation {{method}}", "provider.connect.oauth.code.placeholder": "Code d'autorisation", "provider.connect.oauth.code.required": "Le code d'autorisation est requis", @@ -138,7 +138,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Visitez ", "provider.connect.oauth.auto.visit.link": "ce lien", "provider.connect.oauth.auto.visit.suffix": - " et entrez le code ci-dessous pour connecter votre compte et utiliser les modèles {{provider}} dans OpenCode.", + " et entrez le code ci-dessous pour connecter votre compte et utiliser les modèles {{provider}} dans Kilo.", "provider.connect.oauth.auto.confirmationCode": "Code de confirmation", "provider.connect.toast.connected.title": "{{provider}} connecté", "provider.connect.toast.connected.description": "Les modèles {{provider}} sont maintenant disponibles.", @@ -282,7 +282,7 @@ export const dict = { "dialog.directory.search.placeholder": "Rechercher des dossiers", "dialog.directory.empty": "Aucun dossier trouvé", "dialog.server.title": "Serveurs", - "dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.", + "dialog.server.description": "Changez le serveur Kilo auquel cette application se connecte.", "dialog.server.search.placeholder": "Rechercher des serveurs", "dialog.server.empty": "Aucun serveur pour l'instant", "dialog.server.add.title": "Ajouter un serveur", @@ -399,7 +399,7 @@ export const dict = { "toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}", "toast.update.title": "Mise à jour disponible", "toast.update.description": - "Une nouvelle version d'OpenCode ({{version}}) est maintenant disponible pour installation.", + "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", "toast.update.action.installRestart": "Installer et redémarrer", "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", @@ -409,7 +409,7 @@ export const dict = { "error.page.action.checking": "Vérification...", "error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.updateTo": "Mettre à jour vers {{version}}", - "error.page.report.prefix": "Veuillez signaler cette erreur à l'équipe OpenCode", + "error.page.report.prefix": "Veuillez signaler cette erreur à l'équipe Kilo", "error.page.report.discord": "sur Discord", "error.page.version": "Version : {{version}}", "error.dev.rootNotFound": @@ -427,7 +427,7 @@ export const dict = { "error.chain.modelNotFound": "Modèle introuvable : {{provider}}/{{model}}", "error.chain.checkConfig": "Vérifiez votre configuration (opencode.json) pour les noms de fournisseur/modèle", "error.chain.mcpFailed": - "Le serveur MCP \"{{name}}\" a échoué. Notez qu'OpenCode ne supporte pas encore l'authentification MCP.", + "Le serveur MCP \"{{name}}\" a échoué. Notez qu'Kilo ne supporte pas encore l'authentification MCP.", "error.chain.providerAuthFailed": "Échec de l'authentification du fournisseur ({{provider}}) : {{message}}", "error.chain.providerInitFailed": 'Échec de l\'initialisation du fournisseur "{{provider}}". Vérifiez les identifiants et la configuration.', @@ -550,20 +550,20 @@ export const dict = { "sidebar.workspaces.disable": "Désactiver les espaces de travail", "sidebar.gettingStarted.title": "Commencer", "sidebar.gettingStarted.line1": - "OpenCode inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", "sidebar.gettingStarted.line2": "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", "sidebar.project.viewAllSessions": "Voir toutes les sessions", "sidebar.project.clearNotifications": "Effacer les notifications", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Bureau", "settings.section.server": "Serveur", "settings.tab.general": "Général", "settings.tab.shortcuts": "Raccourcis", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "Intégration WSL", - "settings.desktop.wsl.description": "Exécuter le serveur OpenCode dans WSL sur Windows.", + "settings.desktop.wsl.description": "Exécuter le serveur Kilo dans WSL sur Windows.", "settings.general.section.appearance": "Apparence", "settings.general.section.notifications": "Notifications système", "settings.general.section.updates": "Mises à jour", @@ -571,13 +571,13 @@ export const dict = { "settings.general.section.feed": "Flux", "settings.general.section.display": "Affichage", "settings.general.row.language.title": "Langue", - "settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode", + "settings.general.row.language.description": "Changer la langue d'affichage pour Kilo", "settings.general.row.appearance.title": "Apparence", - "settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil", + "settings.general.row.appearance.description": "Personnaliser l'apparence d'Kilo sur votre appareil", "settings.general.row.colorScheme.title": "Schéma de couleurs", - "settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre", + "settings.general.row.colorScheme.description": "Choisissez si Kilo suit le thème système, clair ou sombre", "settings.general.row.theme.title": "Thème", - "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.", + "settings.general.row.theme.description": "Personnaliser le thème d'Kilo.", "settings.general.row.font.title": "Police de code", "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux", "settings.general.row.uiFont.title": "Police de l'interface", @@ -603,13 +603,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "Notes de version", "settings.general.row.releaseNotes.description": 'Afficher des pop-ups "Quoi de neuf" après les mises à jour', "settings.updates.row.startup.title": "Vérifier les mises à jour au démarrage", - "settings.updates.row.startup.description": "Vérifier automatiquement les mises à jour au lancement d'OpenCode", + "settings.updates.row.startup.description": "Vérifier automatiquement les mises à jour au lancement d'Kilo", "settings.updates.row.check.title": "Vérifier les mises à jour", "settings.updates.row.check.description": "Vérifier manuellement les mises à jour et installer si disponible", "settings.updates.action.checkNow": "Vérifier maintenant", "settings.updates.action.checking": "Vérification...", "settings.updates.toast.latest.title": "Vous êtes à jour", - "settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.", + "settings.updates.toast.latest.description": "Vous utilisez la dernière version d'Kilo.", "sound.option.none": "Aucun", "sound.option.alert01": "Alerte 01", "sound.option.alert02": "Alerte 02", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 3da1c4b43b5..9d1657c12f4 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "モデルを管理", "dialog.model.manage.description": "モデルセレクターに表示するモデルをカスタマイズします。", "dialog.model.manage.provider.toggle": "すべての{{provider}}モデルを切り替え", - "dialog.model.unpaid.freeModels.title": "OpenCodeが提供する無料モデル", + "dialog.model.unpaid.freeModels.title": "Kiloが提供する無料モデル", "dialog.model.unpaid.addMore.title": "人気のプロバイダーからモデルを追加", "dialog.provider.viewAll": "さらにプロバイダーを表示", "provider.connect.title": "{{provider}}を接続", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "認証を待機中...", "provider.connect.status.failed": "認証に失敗しました: {{error}}", "provider.connect.apiKey.description": - "{{provider}}のAPIキーを入力してアカウントを接続し、OpenCodeで{{provider}}モデルを使用します。", + "{{provider}}のAPIキーを入力してアカウントを接続し、Kiloで{{provider}}モデルを使用します。", "provider.connect.apiKey.label": "{{provider}} APIキー", "provider.connect.apiKey.placeholder": "APIキー", "provider.connect.apiKey.required": "APIキーが必要です", @@ -129,7 +129,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": " ", "provider.connect.oauth.code.visit.link": "このリンク", "provider.connect.oauth.code.visit.suffix": - " にアクセスして認証コードを取得し、アカウントを接続してOpenCodeで{{provider}}モデルを使用してください。", + " にアクセスして認証コードを取得し、アカウントを接続してKiloで{{provider}}モデルを使用してください。", "provider.connect.oauth.code.label": "{{method}} 認証コード", "provider.connect.oauth.code.placeholder": "認証コード", "provider.connect.oauth.code.required": "認証コードが必要です", @@ -137,7 +137,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": " ", "provider.connect.oauth.auto.visit.link": "このリンク", "provider.connect.oauth.auto.visit.suffix": - " にアクセスし、以下のコードを入力してアカウントを接続し、OpenCodeで{{provider}}モデルを使用してください。", + " にアクセスし、以下のコードを入力してアカウントを接続し、Kiloで{{provider}}モデルを使用してください。", "provider.connect.oauth.auto.confirmationCode": "確認コード", "provider.connect.toast.connected.title": "{{provider}}が接続されました", "provider.connect.toast.connected.description": "{{provider}}モデルが使用可能になりました。", @@ -280,7 +280,7 @@ export const dict = { "dialog.directory.search.placeholder": "フォルダを検索", "dialog.directory.empty": "フォルダが見つかりません", "dialog.server.title": "サーバー", - "dialog.server.description": "このアプリが接続するOpenCodeサーバーを切り替えます。", + "dialog.server.description": "このアプリが接続するKiloサーバーを切り替えます。", "dialog.server.search.placeholder": "サーバーを検索", "dialog.server.empty": "サーバーはまだありません", "dialog.server.add.title": "サーバーを追加", @@ -395,7 +395,7 @@ export const dict = { "toast.session.unshare.failed.description": "セッションの共有解除中にエラーが発生しました", "toast.session.listFailed.title": "{{project}}のセッション読み込みに失敗しました", "toast.update.title": "アップデートが利用可能です", - "toast.update.description": "OpenCodeの新しいバージョン ({{version}}) がインストール可能です。", + "toast.update.description": "Kiloの新しいバージョン ({{version}}) がインストール可能です。", "toast.update.action.installRestart": "インストールして再起動", "toast.update.action.notYet": "今はしない", "error.page.title": "問題が発生しました", @@ -405,7 +405,7 @@ export const dict = { "error.page.action.checking": "確認中...", "error.page.action.checkUpdates": "アップデートを確認", "error.page.action.updateTo": "{{version}}にアップデート", - "error.page.report.prefix": "このエラーをOpenCodeチームに報告してください: ", + "error.page.report.prefix": "このエラーをKiloチームに報告してください: ", "error.page.report.discord": "Discord", "error.page.version": "バージョン: {{version}}", "error.dev.rootNotFound": @@ -421,7 +421,7 @@ export const dict = { "error.chain.didYouMean": "もしかして: {{suggestions}}", "error.chain.modelNotFound": "モデルが見つかりません: {{provider}}/{{model}}", "error.chain.checkConfig": "config (opencode.json) のプロバイダー/モデル名を確認してください", - "error.chain.mcpFailed": 'MCPサーバー "{{name}}" が失敗しました。注意: OpenCodeはまだMCP認証をサポートしていません。', + "error.chain.mcpFailed": 'MCPサーバー "{{name}}" が失敗しました。注意: KiloはまだMCP認証をサポートしていません。', "error.chain.providerAuthFailed": "プロバイダー認証に失敗しました ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'プロバイダー "{{provider}}" の初期化に失敗しました。認証情報と設定を確認してください。', @@ -541,19 +541,19 @@ export const dict = { "sidebar.workspaces.enable": "ワークスペースを有効化", "sidebar.workspaces.disable": "ワークスペースを無効化", "sidebar.gettingStarted.title": "はじめに", - "sidebar.gettingStarted.line1": "OpenCodeには無料モデルが含まれているため、すぐに開始できます。", + "sidebar.gettingStarted.line1": "Kiloには無料モデルが含まれているため、すぐに開始できます。", "sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。", "sidebar.project.recentSessions": "最近のセッション", "sidebar.project.viewAllSessions": "すべてのセッションを表示", "sidebar.project.clearNotifications": "通知をクリア", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "デスクトップ", "settings.section.server": "サーバー", "settings.tab.general": "一般", "settings.tab.shortcuts": "ショートカット", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL連携", - "settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。", + "settings.desktop.wsl.description": "WindowsのWSL環境でKiloサーバーを実行します。", "settings.general.section.appearance": "外観", "settings.general.section.notifications": "システム通知", "settings.general.section.updates": "アップデート", @@ -561,13 +561,13 @@ export const dict = { "settings.general.section.feed": "フィード", "settings.general.section.display": "ディスプレイ", "settings.general.row.language.title": "言語", - "settings.general.row.language.description": "OpenCodeの表示言語を変更します", + "settings.general.row.language.description": "Kiloの表示言語を変更します", "settings.general.row.appearance.title": "外観", - "settings.general.row.appearance.description": "デバイスでのOpenCodeの表示をカスタマイズします", + "settings.general.row.appearance.description": "デバイスでのKiloの表示をカスタマイズします", "settings.general.row.colorScheme.title": "配色", - "settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します", + "settings.general.row.colorScheme.description": "Kiloがシステム、ライト、またはダークテーマに従うかを選択します", "settings.general.row.theme.title": "テーマ", - "settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。", + "settings.general.row.theme.description": "Kiloのテーマをカスタマイズします。", "settings.general.row.font.title": "コードフォント", "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします", "settings.general.row.uiFont.title": "UIフォント", @@ -592,13 +592,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "リリースノート", "settings.general.row.releaseNotes.description": "アップデート後に「新機能」ポップアップを表示", "settings.updates.row.startup.title": "起動時にアップデートを確認", - "settings.updates.row.startup.description": "OpenCode の起動時に自動でアップデートを確認します", + "settings.updates.row.startup.description": "Kilo の起動時に自動でアップデートを確認します", "settings.updates.row.check.title": "アップデートを確認", "settings.updates.row.check.description": "手動でアップデートを確認し、利用可能ならインストールします", "settings.updates.action.checkNow": "今すぐ確認", "settings.updates.action.checking": "確認中...", "settings.updates.toast.latest.title": "最新です", - "settings.updates.toast.latest.description": "OpenCode は最新バージョンです。", + "settings.updates.toast.latest.description": "Kilo は最新バージョンです。", "sound.option.none": "なし", "sound.option.alert01": "アラート 01", "sound.option.alert02": "アラート 02", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 2b5ccd43d95..18274900092 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "모델 관리", "dialog.model.manage.description": "모델 선택기에 표시할 모델 사용자 지정", "dialog.model.manage.provider.toggle": "모든 {{provider}} 모델 토글", - "dialog.model.unpaid.freeModels.title": "OpenCode에서 제공하는 무료 모델", + "dialog.model.unpaid.freeModels.title": "Kilo에서 제공하는 무료 모델", "dialog.model.unpaid.addMore.title": "인기 공급자의 모델 추가", "dialog.provider.viewAll": "더 많은 공급자 보기", "provider.connect.title": "{{provider}} 연결", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "인증 대기 중...", "provider.connect.status.failed": "인증 실패: {{error}}", "provider.connect.apiKey.description": - "{{provider}} API 키를 입력하여 계정을 연결하고 OpenCode에서 {{provider}} 모델을 사용하세요.", + "{{provider}} API 키를 입력하여 계정을 연결하고 Kilo에서 {{provider}} 모델을 사용하세요.", "provider.connect.apiKey.label": "{{provider}} API 키", "provider.connect.apiKey.placeholder": "API 키", "provider.connect.apiKey.required": "API 키가 필요합니다", @@ -129,7 +129,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "다음 ", "provider.connect.oauth.code.visit.link": "이 링크", "provider.connect.oauth.code.visit.suffix": - "를 방문하여 인증 코드를 받아 계정을 연결하고 OpenCode에서 {{provider}} 모델을 사용하세요.", + "를 방문하여 인증 코드를 받아 계정을 연결하고 Kilo에서 {{provider}} 모델을 사용하세요.", "provider.connect.oauth.code.label": "{{method}} 인증 코드", "provider.connect.oauth.code.placeholder": "인증 코드", "provider.connect.oauth.code.required": "인증 코드가 필요합니다", @@ -137,7 +137,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "다음 ", "provider.connect.oauth.auto.visit.link": "이 링크", "provider.connect.oauth.auto.visit.suffix": - "를 방문하고 아래 코드를 입력하여 계정을 연결하고 OpenCode에서 {{provider}} 모델을 사용하세요.", + "를 방문하고 아래 코드를 입력하여 계정을 연결하고 Kilo에서 {{provider}} 모델을 사용하세요.", "provider.connect.oauth.auto.confirmationCode": "확인 코드", "provider.connect.toast.connected.title": "{{provider}} 연결됨", "provider.connect.toast.connected.description": "이제 {{provider}} 모델을 사용할 수 있습니다.", @@ -280,7 +280,7 @@ export const dict = { "dialog.directory.search.placeholder": "폴더 검색", "dialog.directory.empty": "폴더 없음", "dialog.server.title": "서버", - "dialog.server.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.", + "dialog.server.description": "이 앱이 연결할 Kilo 서버를 전환합니다.", "dialog.server.search.placeholder": "서버 검색", "dialog.server.empty": "서버 없음", "dialog.server.add.title": "서버 추가", @@ -394,7 +394,7 @@ export const dict = { "toast.session.unshare.failed.description": "세션 공유를 해제하는 동안 오류가 발생했습니다", "toast.session.listFailed.title": "{{project}}에 대한 세션을 로드하지 못했습니다", "toast.update.title": "업데이트 가능", - "toast.update.description": "OpenCode의 새 버전({{version}})을 설치할 수 있습니다.", + "toast.update.description": "Kilo의 새 버전({{version}})을 설치할 수 있습니다.", "toast.update.action.installRestart": "설치 및 다시 시작", "toast.update.action.notYet": "나중에", "error.page.title": "문제가 발생했습니다", @@ -404,7 +404,7 @@ export const dict = { "error.page.action.checking": "확인 중...", "error.page.action.checkUpdates": "업데이트 확인", "error.page.action.updateTo": "{{version}} 버전으로 업데이트", - "error.page.report.prefix": "이 오류를 OpenCode 팀에 제보해 주세요: ", + "error.page.report.prefix": "이 오류를 Kilo 팀에 제보해 주세요: ", "error.page.report.discord": "Discord", "error.page.version": "버전: {{version}}", "error.dev.rootNotFound": @@ -420,7 +420,7 @@ export const dict = { "error.chain.didYouMean": "혹시 {{suggestions}}을(를) 의미하셨나요?", "error.chain.modelNotFound": "모델을 찾을 수 없음: {{provider}}/{{model}}", "error.chain.checkConfig": "구성(opencode.json)의 공급자/모델 이름을 확인하세요", - "error.chain.mcpFailed": 'MCP 서버 "{{name}}" 실패. 참고: OpenCode는 아직 MCP 인증을 지원하지 않습니다.', + "error.chain.mcpFailed": 'MCP 서버 "{{name}}" 실패. 참고: Kilo는 아직 MCP 인증을 지원하지 않습니다.', "error.chain.providerAuthFailed": "공급자 인증 실패 ({{provider}}): {{message}}", "error.chain.providerInitFailed": '공급자 "{{provider}}" 초기화 실패. 자격 증명과 구성을 확인하세요.', "error.chain.configJsonInvalid": "{{path}}의 구성 파일이 유효한 JSON(C)가 아닙니다", @@ -538,19 +538,19 @@ export const dict = { "sidebar.workspaces.enable": "작업 공간 활성화", "sidebar.workspaces.disable": "작업 공간 비활성화", "sidebar.gettingStarted.title": "시작하기", - "sidebar.gettingStarted.line1": "OpenCode에는 무료 모델이 포함되어 있어 즉시 시작할 수 있습니다.", + "sidebar.gettingStarted.line1": "Kilo에는 무료 모델이 포함되어 있어 즉시 시작할 수 있습니다.", "sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.", "sidebar.project.recentSessions": "최근 세션", "sidebar.project.viewAllSessions": "모든 세션 보기", "sidebar.project.clearNotifications": "알림 지우기", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "데스크톱", "settings.section.server": "서버", "settings.tab.general": "일반", "settings.tab.shortcuts": "단축키", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL 통합", - "settings.desktop.wsl.description": "Windows의 WSL 내부에서 OpenCode 서버를 실행합니다.", + "settings.desktop.wsl.description": "Windows의 WSL 내부에서 Kilo 서버를 실행합니다.", "settings.general.section.appearance": "모양", "settings.general.section.notifications": "시스템 알림", "settings.general.section.updates": "업데이트", @@ -558,13 +558,13 @@ export const dict = { "settings.general.section.feed": "피드", "settings.general.section.display": "디스플레이", "settings.general.row.language.title": "언어", - "settings.general.row.language.description": "OpenCode 표시 언어 변경", + "settings.general.row.language.description": "Kilo 표시 언어 변경", "settings.general.row.appearance.title": "모양", - "settings.general.row.appearance.description": "기기에서 OpenCode가 보이는 방식 사용자 지정", + "settings.general.row.appearance.description": "기기에서 Kilo가 보이는 방식 사용자 지정", "settings.general.row.colorScheme.title": "색상 테마", - "settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요", + "settings.general.row.colorScheme.description": "Kilo가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요", "settings.general.row.theme.title": "테마", - "settings.general.row.theme.description": "OpenCode 테마 사용자 지정", + "settings.general.row.theme.description": "Kilo 테마 사용자 지정", "settings.general.row.font.title": "코드 글꼴", "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정", "settings.general.row.uiFont.title": "UI 글꼴", @@ -588,13 +588,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "릴리스 노트", "settings.general.row.releaseNotes.description": "업데이트 후 '새 소식' 팝업 표시", "settings.updates.row.startup.title": "시작 시 업데이트 확인", - "settings.updates.row.startup.description": "OpenCode를 실행할 때 업데이트를 자동으로 확인합니다", + "settings.updates.row.startup.description": "Kilo를 실행할 때 업데이트를 자동으로 확인합니다", "settings.updates.row.check.title": "업데이트 확인", "settings.updates.row.check.description": "업데이트를 수동으로 확인하고, 사용 가능하면 설치합니다", "settings.updates.action.checkNow": "지금 확인", "settings.updates.action.checking": "확인 중...", "settings.updates.toast.latest.title": "최신 상태입니다", - "settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.", + "settings.updates.toast.latest.description": "현재 최신 버전의 Kilo를 사용 중입니다.", "sound.option.none": "없음", "sound.option.alert01": "알림 01", "sound.option.alert02": "알림 02", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a0a968179cd..a77f8b025aa 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -118,7 +118,7 @@ export const dict = { "dialog.model.manage.description": "Tilpass hvilke modeller som vises i modellvelgeren.", "dialog.model.manage.provider.toggle": "Veksle alle {{provider}}-modeller", - "dialog.model.unpaid.freeModels.title": "Gratis modeller levert av OpenCode", + "dialog.model.unpaid.freeModels.title": "Gratis modeller levert av Kilo", "dialog.model.unpaid.addMore.title": "Legg til flere modeller fra populære leverandører", "dialog.provider.viewAll": "Vis flere leverandører", @@ -131,7 +131,7 @@ export const dict = { "provider.connect.status.waiting": "Venter på autorisering...", "provider.connect.status.failed": "Autorisering mislyktes: {{error}}", "provider.connect.apiKey.description": - "Skriv inn din {{provider}} API-nøkkel for å koble til kontoen din og bruke {{provider}}-modeller i OpenCode.", + "Skriv inn din {{provider}} API-nøkkel for å koble til kontoen din og bruke {{provider}}-modeller i Kilo.", "provider.connect.apiKey.label": "{{provider}} API-nøkkel", "provider.connect.apiKey.placeholder": "API-nøkkel", "provider.connect.apiKey.required": "API-nøkkel er påkrevd", @@ -145,7 +145,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Besøk ", "provider.connect.oauth.code.visit.link": "denne lenken", "provider.connect.oauth.code.visit.suffix": - " for å hente autorisasjonskoden din for å koble til kontoen din og bruke {{provider}}-modeller i OpenCode.", + " for å hente autorisasjonskoden din for å koble til kontoen din og bruke {{provider}}-modeller i Kilo.", "provider.connect.oauth.code.label": "{{method}} autorisasjonskode", "provider.connect.oauth.code.placeholder": "Autorisasjonskode", "provider.connect.oauth.code.required": "Autorisasjonskode er påkrevd", @@ -153,7 +153,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Besøk ", "provider.connect.oauth.auto.visit.link": "denne lenken", "provider.connect.oauth.auto.visit.suffix": - " og skriv inn koden nedenfor for å koble til kontoen din og bruke {{provider}}-modeller i OpenCode.", + " og skriv inn koden nedenfor for å koble til kontoen din og bruke {{provider}}-modeller i Kilo.", "provider.connect.oauth.auto.confirmationCode": "Bekreftelseskode", "provider.connect.toast.connected.title": "{{provider}} tilkoblet", "provider.connect.toast.connected.description": "{{provider}}-modeller er nå tilgjengelige.", @@ -310,7 +310,7 @@ export const dict = { "dialog.directory.empty": "Ingen mapper funnet", "dialog.server.title": "Servere", - "dialog.server.description": "Bytt hvilken OpenCode-server denne appen kobler til.", + "dialog.server.description": "Bytt hvilken Kilo-server denne appen kobler til.", "dialog.server.search.placeholder": "Søk etter servere", "dialog.server.empty": "Ingen servere ennå", "dialog.server.add.title": "Legg til en server", @@ -442,7 +442,7 @@ export const dict = { "toast.session.listFailed.title": "Kunne ikke laste sesjoner for {{project}}", "toast.update.title": "Oppdatering tilgjengelig", - "toast.update.description": "En ny versjon av OpenCode ({{version}}) er nå tilgjengelig for installasjon.", + "toast.update.description": "En ny versjon av Kilo ({{version}}) er nå tilgjengelig for installasjon.", "toast.update.action.installRestart": "Installer og start på nytt", "toast.update.action.notYet": "Ikke nå", @@ -453,7 +453,7 @@ export const dict = { "error.page.action.checking": "Sjekker...", "error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.updateTo": "Oppdater til {{version}}", - "error.page.report.prefix": "Vennligst rapporter denne feilen til OpenCode-teamet", + "error.page.report.prefix": "Vennligst rapporter denne feilen til Kilo-teamet", "error.page.report.discord": "på Discord", "error.page.version": "Versjon: {{version}}", @@ -472,7 +472,7 @@ export const dict = { "error.chain.didYouMean": "Mente du: {{suggestions}}", "error.chain.modelNotFound": "Modell ikke funnet: {{provider}}/{{model}}", "error.chain.checkConfig": "Sjekk leverandør-/modellnavnene i konfigurasjonen din (opencode.json)", - "error.chain.mcpFailed": 'MCP-server "{{name}}" mislyktes. Merk at OpenCode ikke støtter MCP-autentisering ennå.', + "error.chain.mcpFailed": 'MCP-server "{{name}}" mislyktes. Merk at Kilo ikke støtter MCP-autentisering ennå.', "error.chain.providerAuthFailed": "Leverandørautentisering mislyktes ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Kunne ikke initialisere leverandør "{{provider}}". Sjekk legitimasjon og konfigurasjon.', @@ -608,13 +608,13 @@ export const dict = { "sidebar.workspaces.enable": "Aktiver arbeidsområder", "sidebar.workspaces.disable": "Deaktiver arbeidsområder", "sidebar.gettingStarted.title": "Kom i gang", - "sidebar.gettingStarted.line1": "OpenCode inkluderer gratis modeller så du kan starte umiddelbart.", + "sidebar.gettingStarted.line1": "Kilo inkluderer gratis modeller så du kan starte umiddelbart.", "sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.project.recentSessions": "Nylige sesjoner", "sidebar.project.viewAllSessions": "Vis alle sesjoner", "sidebar.project.clearNotifications": "Fjern varsler", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Skrivebord", "settings.section.server": "Server", @@ -622,7 +622,7 @@ export const dict = { "settings.tab.shortcuts": "Snarveier", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL-integrasjon", - "settings.desktop.wsl.description": "Kjør OpenCode-serveren i WSL på Windows.", + "settings.desktop.wsl.description": "Kjør Kilo-serveren i WSL på Windows.", "settings.general.section.appearance": "Utseende", "settings.general.section.notifications": "Systemvarsler", @@ -632,13 +632,13 @@ export const dict = { "settings.general.section.display": "Skjerm", "settings.general.row.language.title": "Språk", - "settings.general.row.language.description": "Endre visningsspråket for OpenCode", + "settings.general.row.language.description": "Endre visningsspråket for Kilo", "settings.general.row.appearance.title": "Utseende", - "settings.general.row.appearance.description": "Tilpass hvordan OpenCode ser ut på enheten din", + "settings.general.row.appearance.description": "Tilpass hvordan Kilo ser ut på enheten din", "settings.general.row.colorScheme.title": "Fargevalg", - "settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema", + "settings.general.row.colorScheme.description": "Velg om Kilo skal følge systemets, lyst eller mørkt tema", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.", + "settings.general.row.theme.description": "Tilpass hvordan Kilo er tematisert.", "settings.general.row.font.title": "Kodefont", "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler", "settings.general.row.uiFont.title": "UI-skrift", @@ -663,13 +663,13 @@ export const dict = { "settings.general.row.releaseNotes.description": 'Vis "Hva er nytt"-vinduer etter oppdateringer', "settings.updates.row.startup.title": "Se etter oppdateringer ved oppstart", - "settings.updates.row.startup.description": "Se automatisk etter oppdateringer når OpenCode starter", + "settings.updates.row.startup.description": "Se automatisk etter oppdateringer når Kilo starter", "settings.updates.row.check.title": "Se etter oppdateringer", "settings.updates.row.check.description": "Se etter oppdateringer manuelt og installer hvis tilgjengelig", "settings.updates.action.checkNow": "Sjekk nå", "settings.updates.action.checking": "Sjekker...", "settings.updates.toast.latest.title": "Du er oppdatert", - "settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.", + "settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av Kilo.", "sound.option.none": "Ingen", "sound.option.alert01": "Varsel 01", "sound.option.alert02": "Varsel 02", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 88d209f11ff..64170ee1d64 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -105,7 +105,7 @@ export const dict = { "dialog.model.manage": "Zarządzaj modelami", "dialog.model.manage.description": "Dostosuj, które modele pojawiają się w wyborze modelu.", "dialog.model.manage.provider.toggle": "Przełącz wszystkie modele {{provider}}", - "dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez OpenCode", + "dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez Kilo", "dialog.model.unpaid.addMore.title": "Dodaj więcej modeli od popularnych dostawców", "dialog.provider.viewAll": "Zobacz więcej dostawców", "provider.connect.title": "Połącz {{provider}}", @@ -116,7 +116,7 @@ export const dict = { "provider.connect.status.waiting": "Oczekiwanie na autoryzację...", "provider.connect.status.failed": "Autoryzacja nie powiodła się: {{error}}", "provider.connect.apiKey.description": - "Wprowadź swój klucz API {{provider}}, aby połączyć konto i używać modeli {{provider}} w OpenCode.", + "Wprowadź swój klucz API {{provider}}, aby połączyć konto i używać modeli {{provider}} w Kilo.", "provider.connect.apiKey.label": "Klucz API {{provider}}", "provider.connect.apiKey.placeholder": "Klucz API", "provider.connect.apiKey.required": "Klucz API jest wymagany", @@ -130,7 +130,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Odwiedź ", "provider.connect.oauth.code.visit.link": "ten link", "provider.connect.oauth.code.visit.suffix": - ", aby odebrać kod autoryzacyjny, połączyć konto i używać modeli {{provider}} w OpenCode.", + ", aby odebrać kod autoryzacyjny, połączyć konto i używać modeli {{provider}} w Kilo.", "provider.connect.oauth.code.label": "Kod autoryzacyjny {{method}}", "provider.connect.oauth.code.placeholder": "Kod autoryzacyjny", "provider.connect.oauth.code.required": "Kod autoryzacyjny jest wymagany", @@ -138,7 +138,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Odwiedź ", "provider.connect.oauth.auto.visit.link": "ten link", "provider.connect.oauth.auto.visit.suffix": - " i wprowadź poniższy kod, aby połączyć konto i używać modeli {{provider}} w OpenCode.", + " i wprowadź poniższy kod, aby połączyć konto i używać modeli {{provider}} w Kilo.", "provider.connect.oauth.auto.confirmationCode": "Kod potwierdzający", "provider.connect.toast.connected.title": "Połączono {{provider}}", "provider.connect.toast.connected.description": "Modele {{provider}} są teraz dostępne do użycia.", @@ -282,7 +282,7 @@ export const dict = { "dialog.directory.search.placeholder": "Szukaj folderów", "dialog.directory.empty": "Nie znaleziono folderów", "dialog.server.title": "Serwery", - "dialog.server.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.", + "dialog.server.description": "Przełącz serwer Kilo, z którym łączy się ta aplikacja.", "dialog.server.search.placeholder": "Szukaj serwerów", "dialog.server.empty": "Brak serwerów", "dialog.server.add.title": "Dodaj serwer", @@ -396,7 +396,7 @@ export const dict = { "toast.session.unshare.failed.description": "Wystąpił błąd podczas zatrzymywania udostępniania sesji", "toast.session.listFailed.title": "Nie udało się załadować sesji dla {{project}}", "toast.update.title": "Dostępna aktualizacja", - "toast.update.description": "Nowa wersja OpenCode ({{version}}) jest teraz dostępna do instalacji.", + "toast.update.description": "Nowa wersja Kilo ({{version}}) jest teraz dostępna do instalacji.", "toast.update.action.installRestart": "Zainstaluj i zrestartuj", "toast.update.action.notYet": "Jeszcze nie", "error.page.title": "Coś poszło nie tak", @@ -406,7 +406,7 @@ export const dict = { "error.page.action.checking": "Sprawdzanie...", "error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.updateTo": "Zaktualizuj do {{version}}", - "error.page.report.prefix": "Proszę zgłosić ten błąd do zespołu OpenCode", + "error.page.report.prefix": "Proszę zgłosić ten błąd do zespołu Kilo", "error.page.report.discord": "na Discordzie", "error.page.version": "Wersja: {{version}}", "error.dev.rootNotFound": @@ -422,7 +422,7 @@ export const dict = { "error.chain.didYouMean": "Czy miałeś na myśli: {{suggestions}}", "error.chain.modelNotFound": "Model nie znaleziony: {{provider}}/{{model}}", "error.chain.checkConfig": "Sprawdź swoją konfigurację (opencode.json) nazwy dostawców/modeli", - "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.', + "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, Kilo does not support MCP authentication yet.', "error.chain.providerAuthFailed": "Uwierzytelnianie dostawcy nie powiodło się ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Nie udało się zainicjować dostawcy "{{provider}}". Sprawdź poświadczenia i konfigurację.', @@ -542,19 +542,19 @@ export const dict = { "sidebar.workspaces.enable": "Włącz przestrzenie robocze", "sidebar.workspaces.disable": "Wyłącz przestrzenie robocze", "sidebar.gettingStarted.title": "Pierwsze kroki", - "sidebar.gettingStarted.line1": "OpenCode zawiera darmowe modele, więc możesz zacząć od razu.", + "sidebar.gettingStarted.line1": "Kilo zawiera darmowe modele, więc możesz zacząć od razu.", "sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.", "sidebar.project.recentSessions": "Ostatnie sesje", "sidebar.project.viewAllSessions": "Zobacz wszystkie sesje", "sidebar.project.clearNotifications": "Wyczyść powiadomienia", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Pulpit", "settings.section.server": "Serwer", "settings.tab.general": "Ogólne", "settings.tab.shortcuts": "Skróty", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", - "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", + "settings.desktop.wsl.description": "Run the Kilo server inside WSL on Windows.", "settings.general.section.appearance": "Wygląd", "settings.general.section.notifications": "Powiadomienia systemowe", "settings.general.section.updates": "Aktualizacje", @@ -562,14 +562,14 @@ export const dict = { "settings.general.section.feed": "Kanał", "settings.general.section.display": "Ekran", "settings.general.row.language.title": "Język", - "settings.general.row.language.description": "Zmień język wyświetlania dla OpenCode", + "settings.general.row.language.description": "Zmień język wyświetlania dla Kilo", "settings.general.row.appearance.title": "Wygląd", - "settings.general.row.appearance.description": "Dostosuj wygląd OpenCode na swoim urządzeniu", + "settings.general.row.appearance.description": "Dostosuj wygląd Kilo na swoim urządzeniu", "settings.general.row.colorScheme.title": "Schemat kolorów", "settings.general.row.colorScheme.description": - "Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego", + "Wybierz, czy Kilo ma używać motywu systemowego, jasnego czy ciemnego", "settings.general.row.theme.title": "Motyw", - "settings.general.row.theme.description": "Dostosuj motyw OpenCode.", + "settings.general.row.theme.description": "Dostosuj motyw Kilo.", "settings.general.row.font.title": "Czcionka kodu", "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach", "settings.general.row.uiFont.title": "Czcionka interfejsu", @@ -593,13 +593,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "Informacje o wydaniu", "settings.general.row.releaseNotes.description": 'Pokazuj wyskakujące okna "Co nowego" po aktualizacjach', "settings.updates.row.startup.title": "Sprawdzaj aktualizacje przy uruchomieniu", - "settings.updates.row.startup.description": "Automatycznie sprawdzaj aktualizacje podczas uruchamiania OpenCode", + "settings.updates.row.startup.description": "Automatycznie sprawdzaj aktualizacje podczas uruchamiania Kilo", "settings.updates.row.check.title": "Sprawdź aktualizacje", "settings.updates.row.check.description": "Ręcznie sprawdź aktualizacje i zainstaluj, jeśli są dostępne", "settings.updates.action.checkNow": "Sprawdź teraz", "settings.updates.action.checking": "Sprawdzanie...", "settings.updates.toast.latest.title": "Masz najnowszą wersję", - "settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.", + "settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji Kilo.", "sound.option.none": "Brak", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 688289b7e81..89c9862951b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "Настройте какие модели появляются в выборе модели", "dialog.model.manage.provider.toggle": "Переключить все модели {{provider}}", - "dialog.model.unpaid.freeModels.title": "Бесплатные модели от OpenCode", + "dialog.model.unpaid.freeModels.title": "Бесплатные модели от Kilo", "dialog.model.unpaid.addMore.title": "Добавьте больше моделей от популярных провайдеров", "dialog.provider.viewAll": "Показать больше провайдеров", @@ -128,7 +128,7 @@ export const dict = { "provider.connect.status.waiting": "Ожидание авторизации...", "provider.connect.status.failed": "Ошибка авторизации: {{error}}", "provider.connect.apiKey.description": - "Введите ваш API ключ {{provider}} для подключения аккаунта и использования моделей {{provider}} в OpenCode.", + "Введите ваш API ключ {{provider}} для подключения аккаунта и использования моделей {{provider}} в Kilo.", "provider.connect.apiKey.label": "{{provider}} API ключ", "provider.connect.apiKey.placeholder": "API ключ", "provider.connect.apiKey.required": "API ключ обязателен", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "Посетите ", "provider.connect.oauth.code.visit.link": "эту ссылку", "provider.connect.oauth.code.visit.suffix": - " чтобы получить код авторизации для подключения аккаунта и использования моделей {{provider}} в OpenCode.", + " чтобы получить код авторизации для подключения аккаунта и использования моделей {{provider}} в Kilo.", "provider.connect.oauth.code.label": "{{method}} код авторизации", "provider.connect.oauth.code.placeholder": "Код авторизации", "provider.connect.oauth.code.required": "Код авторизации обязателен", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Посетите ", "provider.connect.oauth.auto.visit.link": "эту ссылку", "provider.connect.oauth.auto.visit.suffix": - " и введите код ниже для подключения аккаунта и использования моделей {{provider}} в OpenCode.", + " и введите код ниже для подключения аккаунта и использования моделей {{provider}} в Kilo.", "provider.connect.oauth.auto.confirmationCode": "Код подтверждения", "provider.connect.toast.connected.title": "{{provider}} подключён", "provider.connect.toast.connected.description": "Модели {{provider}} теперь доступны.", @@ -307,7 +307,7 @@ export const dict = { "dialog.directory.empty": "Папки не найдены", "dialog.server.title": "Серверы", - "dialog.server.description": "Переключите сервер OpenCode к которому подключается приложение.", + "dialog.server.description": "Переключите сервер Kilo к которому подключается приложение.", "dialog.server.search.placeholder": "Поиск серверов", "dialog.server.empty": "Серверов пока нет", "dialog.server.add.title": "Добавить сервер", @@ -440,7 +440,7 @@ export const dict = { "toast.session.listFailed.title": "Не удалось загрузить сессии для {{project}}", "toast.update.title": "Доступно обновление", - "toast.update.description": "Новая версия OpenCode ({{version}}) доступна для установки.", + "toast.update.description": "Новая версия Kilo ({{version}}) доступна для установки.", "toast.update.action.installRestart": "Установить и перезапустить", "toast.update.action.notYet": "Пока нет", @@ -451,7 +451,7 @@ export const dict = { "error.page.action.checking": "Проверка...", "error.page.action.checkUpdates": "Проверить обновления", "error.page.action.updateTo": "Обновить до {{version}}", - "error.page.report.prefix": "Пожалуйста, сообщите об этой ошибке команде OpenCode", + "error.page.report.prefix": "Пожалуйста, сообщите об этой ошибке команде Kilo", "error.page.report.discord": "в Discord", "error.page.version": "Версия: {{version}}", @@ -471,7 +471,7 @@ export const dict = { "error.chain.modelNotFound": "Модель не найдена: {{provider}}/{{model}}", "error.chain.checkConfig": "Проверьте названия провайдера/модели в конфиге (opencode.json)", "error.chain.mcpFailed": - 'MCP сервер "{{name}}" завершился с ошибкой. Обратите внимание, что OpenCode пока не поддерживает MCP авторизацию.', + 'MCP сервер "{{name}}" завершился с ошибкой. Обратите внимание, что Kilo пока не поддерживает MCP авторизацию.', "error.chain.providerAuthFailed": "Ошибка аутентификации провайдера ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Не удалось инициализировать провайдера "{{provider}}". Проверьте учётные данные и конфигурацию.', @@ -605,21 +605,21 @@ export const dict = { "sidebar.workspaces.enable": "Включить рабочие пространства", "sidebar.workspaces.disable": "Отключить рабочие пространства", "sidebar.gettingStarted.title": "Начало работы", - "sidebar.gettingStarted.line1": "OpenCode включает бесплатные модели, чтобы вы могли начать сразу.", + "sidebar.gettingStarted.line1": "Kilo включает бесплатные модели, чтобы вы могли начать сразу.", "sidebar.gettingStarted.line2": "Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.", "sidebar.project.recentSessions": "Недавние сессии", "sidebar.project.viewAllSessions": "Посмотреть все сессии", "sidebar.project.clearNotifications": "Очистить уведомления", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Приложение", "settings.section.server": "Сервер", "settings.tab.general": "Основные", "settings.tab.shortcuts": "Горячие клавиши", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "Интеграция с WSL", - "settings.desktop.wsl.description": "Запускать сервер OpenCode внутри WSL на Windows.", + "settings.desktop.wsl.description": "Запускать сервер Kilo внутри WSL на Windows.", "settings.general.section.appearance": "Внешний вид", "settings.general.section.notifications": "Системные уведомления", @@ -629,13 +629,13 @@ export const dict = { "settings.general.section.display": "Дисплей", "settings.general.row.language.title": "Язык", - "settings.general.row.language.description": "Изменить язык отображения OpenCode", + "settings.general.row.language.description": "Изменить язык отображения Kilo", "settings.general.row.appearance.title": "Внешний вид", - "settings.general.row.appearance.description": "Настройте как OpenCode выглядит на вашем устройстве", + "settings.general.row.appearance.description": "Настройте как Kilo выглядит на вашем устройстве", "settings.general.row.colorScheme.title": "Цветовая схема", - "settings.general.row.colorScheme.description": "Выберите, следует ли OpenCode системной, светлой или тёмной теме", + "settings.general.row.colorScheme.description": "Выберите, следует ли Kilo системной, светлой или тёмной теме", "settings.general.row.theme.title": "Тема", - "settings.general.row.theme.description": "Настройте оформление OpenCode.", + "settings.general.row.theme.description": "Настройте оформление Kilo.", "settings.general.row.font.title": "Шрифт кода", "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах", "settings.general.row.uiFont.title": "Шрифт интерфейса", @@ -663,13 +663,13 @@ export const dict = { "settings.general.row.releaseNotes.description": 'Показывать всплывающие окна "Что нового" после обновлений', "settings.updates.row.startup.title": "Проверять обновления при запуске", - "settings.updates.row.startup.description": "Автоматически проверять обновления при запуске OpenCode", + "settings.updates.row.startup.description": "Автоматически проверять обновления при запуске Kilo", "settings.updates.row.check.title": "Проверить обновления", "settings.updates.row.check.description": "Проверить обновления вручную и установить, если доступны", "settings.updates.action.checkNow": "Проверить сейчас", "settings.updates.action.checking": "Проверка...", "settings.updates.toast.latest.title": "У вас последняя версия", - "settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.", + "settings.updates.toast.latest.description": "Вы используете последнюю версию Kilo.", "sound.option.none": "Нет", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 5decf3adb53..4a6bffdcbb5 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "ปรับแต่งโมเดลที่จะปรากฏในตัวเลือกโมเดล", "dialog.model.manage.provider.toggle": "สลับโมเดลทั้งหมดของ {{provider}}", - "dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย OpenCode", + "dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย Kilo", "dialog.model.unpaid.addMore.title": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม", "dialog.provider.viewAll": "แสดงผู้ให้บริการเพิ่มเติม", @@ -128,7 +128,7 @@ export const dict = { "provider.connect.status.waiting": "รอการอนุญาต...", "provider.connect.status.failed": "การอนุญาตล้มเหลว: {{error}}", "provider.connect.apiKey.description": - "ป้อนคีย์ API ของ {{provider}} เพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode", + "ป้อนคีย์ API ของ {{provider}} เพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.apiKey.label": "คีย์ API ของ {{provider}}", "provider.connect.apiKey.placeholder": "คีย์ API", "provider.connect.apiKey.required": "ต้องใช้คีย์ API", @@ -142,7 +142,7 @@ export const dict = { "provider.connect.oauth.code.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.code.visit.link": "ลิงก์นี้", "provider.connect.oauth.code.visit.suffix": - " เพื่อรวบรวมรหัสการอนุญาตของคุณเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode", + " เพื่อรวบรวมรหัสการอนุญาตของคุณเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.code.label": "รหัสการอนุญาต {{method}}", "provider.connect.oauth.code.placeholder": "รหัสการอนุญาต", "provider.connect.oauth.code.required": "ต้องใช้รหัสการอนุญาต", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.auto.visit.link": "ลิงก์นี้", "provider.connect.oauth.auto.visit.suffix": - " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode", + " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน", "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว", "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", @@ -307,7 +307,7 @@ export const dict = { "dialog.directory.empty": "ไม่พบโฟลเดอร์", "dialog.server.title": "เซิร์ฟเวอร์", - "dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย", + "dialog.server.description": "สลับเซิร์ฟเวอร์ Kilo ที่แอปนี้เชื่อมต่อด้วย", "dialog.server.search.placeholder": "ค้นหาเซิร์ฟเวอร์", "dialog.server.empty": "ยังไม่มีเซิร์ฟเวอร์", "dialog.server.add.title": "เพิ่มเซิร์ฟเวอร์", @@ -439,7 +439,7 @@ export const dict = { "toast.session.listFailed.title": "ไม่สามารถโหลดเซสชันสำหรับ {{project}}", "toast.update.title": "มีการอัปเดต", - "toast.update.description": "เวอร์ชันใหม่ของ OpenCode ({{version}}) พร้อมใช้งานสำหรับติดตั้ง", + "toast.update.description": "เวอร์ชันใหม่ของ Kilo ({{version}}) พร้อมใช้งานสำหรับติดตั้ง", "toast.update.action.installRestart": "ติดตั้งและรีสตาร์ท", "toast.update.action.notYet": "ยังไม่", @@ -450,7 +450,7 @@ export const dict = { "error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.updateTo": "อัปเดตเป็น {{version}}", - "error.page.report.prefix": "โปรดรายงานข้อผิดพลาดนี้ให้ทีม OpenCode", + "error.page.report.prefix": "โปรดรายงานข้อผิดพลาดนี้ให้ทีม Kilo", "error.page.report.discord": "บน Discord", "error.page.version": "เวอร์ชัน: {{version}}", @@ -468,7 +468,7 @@ export const dict = { "error.chain.didYouMean": "คุณหมายถึง: {{suggestions}}", "error.chain.modelNotFound": "ไม่พบโมเดล: {{provider}}/{{model}}", "error.chain.checkConfig": "ตรวจสอบการกำหนดค่าของคุณ (opencode.json) ชื่อผู้ให้บริการ/โมเดล", - "error.chain.mcpFailed": 'เซิร์ฟเวอร์ MCP "{{name}}" ล้มเหลว โปรดทราบว่า OpenCode ยังไม่รองรับการตรวจสอบสิทธิ์ MCP', + "error.chain.mcpFailed": 'เซิร์ฟเวอร์ MCP "{{name}}" ล้มเหลว โปรดทราบว่า Kilo ยังไม่รองรับการตรวจสอบสิทธิ์ MCP', "error.chain.providerAuthFailed": "การตรวจสอบสิทธิ์ผู้ให้บริการล้มเหลว ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'ไม่สามารถเริ่มต้นผู้ให้บริการ "{{provider}}" ตรวจสอบข้อมูลรับรองและการกำหนดค่า', "error.chain.configJsonInvalid": "ไฟล์กำหนดค่าที่ {{path}} ไม่ใช่ JSON(C) ที่ถูกต้อง", @@ -599,13 +599,13 @@ export const dict = { "sidebar.workspaces.enable": "เปิดใช้งานพื้นที่ทำงาน", "sidebar.workspaces.disable": "ปิดใช้งานพื้นที่ทำงาน", "sidebar.gettingStarted.title": "เริ่มต้นใช้งาน", - "sidebar.gettingStarted.line1": "OpenCode รวมถึงโมเดลฟรีเพื่อให้คุณเริ่มต้นได้ทันที", + "sidebar.gettingStarted.line1": "Kilo รวมถึงโมเดลฟรีเพื่อให้คุณเริ่มต้นได้ทันที", "sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ", "sidebar.project.recentSessions": "เซสชันล่าสุด", "sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด", "sidebar.project.clearNotifications": "ล้างการแจ้งเตือน", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "เดสก์ท็อป", "settings.section.server": "เซิร์ฟเวอร์", @@ -613,7 +613,7 @@ export const dict = { "settings.tab.shortcuts": "ทางลัด", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "การรวม WSL", - "settings.desktop.wsl.description": "เรียกใช้เซิร์ฟเวอร์ OpenCode ภายใน WSL บน Windows", + "settings.desktop.wsl.description": "เรียกใช้เซิร์ฟเวอร์ Kilo ภายใน WSL บน Windows", "settings.general.section.appearance": "รูปลักษณ์", "settings.general.section.notifications": "การแจ้งเตือนระบบ", @@ -623,13 +623,13 @@ export const dict = { "settings.general.section.display": "การแสดงผล", "settings.general.row.language.title": "ภาษา", - "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode", + "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ Kilo", "settings.general.row.appearance.title": "รูปลักษณ์", - "settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ", + "settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ Kilo มีลักษณะบนอุปกรณ์ของคุณ", "settings.general.row.colorScheme.title": "โทนสี", - "settings.general.row.colorScheme.description": "เลือกว่าจะให้ OpenCode ใช้ธีมตามระบบ สว่าง หรือมืด", + "settings.general.row.colorScheme.description": "เลือกว่าจะให้ Kilo ใช้ธีมตามระบบ สว่าง หรือมืด", "settings.general.row.theme.title": "ธีม", - "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม", + "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ Kilo มีธีม", "settings.general.row.font.title": "ฟอนต์โค้ด", "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล", "settings.general.row.uiFont.title": "ฟอนต์ UI", @@ -653,13 +653,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "แสดงป๊อปอัพ What's New หลังจากอัปเดต", "settings.updates.row.startup.title": "ตรวจสอบการอัปเดตเมื่อเริ่มต้น", - "settings.updates.row.startup.description": "ตรวจสอบการอัปเดตโดยอัตโนมัติเมื่อ OpenCode เปิดใช้งาน", + "settings.updates.row.startup.description": "ตรวจสอบการอัปเดตโดยอัตโนมัติเมื่อ Kilo เปิดใช้งาน", "settings.updates.row.check.title": "ตรวจสอบการอัปเดต", "settings.updates.row.check.description": "ตรวจสอบการอัปเดตด้วยตนเองและติดตั้งหากมี", "settings.updates.action.checkNow": "ตรวจสอบทันที", "settings.updates.action.checking": "กำลังตรวจสอบ...", "settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว", - "settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด", + "settings.updates.toast.latest.description": "คุณกำลังใช้งาน Kilo เวอร์ชันล่าสุด", "sound.option.none": "ไม่มี", "sound.option.alert01": "เสียงเตือน 01", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 6a3ade0d0b0..f6abb762f5e 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -119,7 +119,7 @@ export const dict = { "dialog.model.manage.description": "Model seçicide hangi modellerin görüneceğini özelleştirin.", "dialog.model.manage.provider.toggle": "Tüm {{provider}} modellerini aç/kapat", - "dialog.model.unpaid.freeModels.title": "OpenCode tarafından sunulan ücretsiz modeller", + "dialog.model.unpaid.freeModels.title": "Kilo tarafından sunulan ücretsiz modeller", "dialog.model.unpaid.addMore.title": "Popüler sağlayıcılardan daha fazla model ekleyin", "dialog.provider.viewAll": "Daha fazla sağlayıcı göster", @@ -132,7 +132,7 @@ export const dict = { "provider.connect.status.waiting": "Yetkilendirme bekleniyor...", "provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}", "provider.connect.apiKey.description": - "{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.", + "{{provider}} hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.", "provider.connect.apiKey.label": "{{provider}} API anahtarı", "provider.connect.apiKey.placeholder": "API anahtarı", "provider.connect.apiKey.required": "API anahtarı gerekli", @@ -144,7 +144,7 @@ export const dict = { "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.", "provider.connect.oauth.code.visit.prefix": - "Hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için ", + "Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ", "provider.connect.oauth.code.visit.link": "bu bağlantıya", "provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.", "provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu", @@ -154,7 +154,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "", "provider.connect.oauth.auto.visit.link": "Bu bağlantıya", "provider.connect.oauth.auto.visit.suffix": - " tıklayarak aşağıdaki kodu girin ve hesabınızı bağlayarak OpenCode'da {{provider}} modellerini kullanın.", + " tıklayarak aşağıdaki kodu girin ve hesabınızı bağlayarak Kilo'da {{provider}} modellerini kullanın.", "provider.connect.oauth.auto.confirmationCode": "Onay kodu", "provider.connect.toast.connected.title": "{{provider}} bağlandı", "provider.connect.toast.connected.description": "{{provider}} modelleri artık kullanımda.", @@ -312,7 +312,7 @@ export const dict = { "dialog.directory.empty": "Klasör bulunamadı", "dialog.server.title": "Sunucular", - "dialog.server.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.", + "dialog.server.description": "Bu uygulamanın hangi Kilo sunucusuna bağlanacağını değiştirin.", "dialog.server.search.placeholder": "Sunucu ara", "dialog.server.empty": "Henüz sunucu yok", "dialog.server.add.title": "Sunucu ekle", @@ -444,7 +444,7 @@ export const dict = { "toast.session.listFailed.title": "{{project}} için oturumlar yüklenemedi", "toast.update.title": "Güncelleme mevcut", - "toast.update.description": "OpenCode'un yeni bir sürümü ({{version}}) yüklemeye hazır.", + "toast.update.description": "Kilo'un yeni bir sürümü ({{version}}) yüklemeye hazır.", "toast.update.action.installRestart": "Yükle ve yeniden başlat", "toast.update.action.notYet": "Şimdi değil", @@ -455,7 +455,7 @@ export const dict = { "error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.updateTo": "{{version}} sürümüne güncelle", - "error.page.report.prefix": "Lütfen bu hatayı OpenCode ekibine bildirin", + "error.page.report.prefix": "Lütfen bu hatayı Kilo ekibine bildirin", "error.page.report.discord": "Discord üzerinden", "error.page.version": "Sürüm: {{version}}", @@ -475,7 +475,7 @@ export const dict = { "error.chain.modelNotFound": "Model bulunamadı: {{provider}}/{{model}}", "error.chain.checkConfig": "Yapılandırma dosyanızı (opencode.json) sağlayıcı/model adlarını kontrol edin", "error.chain.mcpFailed": - 'MCP sunucusu "{{name}}" başarısız oldu. Not: OpenCode henüz MCP kimlik doğrulamasını desteklemiyor.', + 'MCP sunucusu "{{name}}" başarısız oldu. Not: Kilo henüz MCP kimlik doğrulamasını desteklemiyor.', "error.chain.providerAuthFailed": "Sağlayıcı kimlik doğrulaması başarısız ({{provider}}): {{message}}", "error.chain.providerInitFailed": '"{{provider}}" sağlayıcısı başlatılamadı. Kimlik bilgilerini ve yapılandırmayı kontrol edin.', @@ -611,13 +611,13 @@ export const dict = { "sidebar.workspaces.enable": "Çalışma alanlarını etkinleştir", "sidebar.workspaces.disable": "Çalışma alanlarını devre dışı bırak", "sidebar.gettingStarted.title": "Başlarken", - "sidebar.gettingStarted.line1": "OpenCode ücretsiz modeller içerir, böylece hemen başlayabilirsiniz.", + "sidebar.gettingStarted.line1": "Kilo ücretsiz modeller içerir, böylece hemen başlayabilirsiniz.", "sidebar.gettingStarted.line2": "Claude, GPT, Gemini vb. modelleri kullanmak için herhangi bir sağlayıcı bağlayın.", "sidebar.project.recentSessions": "Son oturumlar", "sidebar.project.viewAllSessions": "Tüm oturumları görüntüle", "sidebar.project.clearNotifications": "Bildirimleri temizle", - "app.name.desktop": "OpenCode Masaüstü", + "app.name.desktop": "Kilo Masaüstü", "settings.section.desktop": "Masaüstü", "settings.section.server": "Sunucu", @@ -625,7 +625,7 @@ export const dict = { "settings.tab.shortcuts": "Kısayollar", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL entegrasyonu", - "settings.desktop.wsl.description": "OpenCode sunucusunu Windows'ta WSL içinde çalıştırın.", + "settings.desktop.wsl.description": "Kilo sunucusunu Windows'ta WSL içinde çalıştırın.", "settings.general.section.appearance": "Görünüm", "settings.general.section.notifications": "Sistem bildirimleri", @@ -635,14 +635,14 @@ export const dict = { "settings.general.section.display": "Ekran", "settings.general.row.language.title": "Dil", - "settings.general.row.language.description": "OpenCode'un görünüm dilini değiştirin", + "settings.general.row.language.description": "Kilo'un görünüm dilini değiştirin", "settings.general.row.appearance.title": "Görünüm", - "settings.general.row.appearance.description": "OpenCode'un cihazınızdaki görünümünü özelleştirin", + "settings.general.row.appearance.description": "Kilo'un cihazınızdaki görünümünü özelleştirin", "settings.general.row.colorScheme.title": "Renk şeması", "settings.general.row.colorScheme.description": - "OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin", + "Kilo'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin", "settings.general.row.theme.title": "Tema", - "settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.", + "settings.general.row.theme.description": "Kilo'un temasını özelleştirin.", "settings.general.row.font.title": "Kod Yazı Tipi", "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin", "settings.general.row.uiFont.title": "Arayüz Yazı Tipi", @@ -671,13 +671,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "Güncellemelerden sonra Yenilikler bildirimlerini göster", "settings.updates.row.startup.title": "Başlangıçta güncellemeleri kontrol et", - "settings.updates.row.startup.description": "OpenCode başladığında otomatik güncelleme kontrolü yap", + "settings.updates.row.startup.description": "Kilo başladığında otomatik güncelleme kontrolü yap", "settings.updates.row.check.title": "Güncellemeleri kontrol et", "settings.updates.row.check.description": "Elle güncelleme kontrolü yap ve varsa yükle", "settings.updates.action.checkNow": "Şimdi kontrol et", "settings.updates.action.checking": "Kontrol ediliyor...", "settings.updates.toast.latest.title": "Güncelsiniz", - "settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.", + "settings.updates.toast.latest.description": "Kilo'un en son sürümünü kullanıyorsunuz.", "sound.option.none": "Yok", "sound.option.alert01": "Uyarı 01", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 28231733eab..840c5ac9e11 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -141,7 +141,7 @@ export const dict = { "dialog.model.manage": "管理模型", "dialog.model.manage.description": "自定义模型选择器中显示的模型。", "dialog.model.manage.provider.toggle": "切换所有 {{provider}} 模型", - "dialog.model.unpaid.freeModels.title": "OpenCode 提供的免费模型", + "dialog.model.unpaid.freeModels.title": "Kilo 提供的免费模型", "dialog.model.unpaid.addMore.title": "从热门提供商添加更多模型", "dialog.provider.viewAll": "查看更多提供商", @@ -154,7 +154,7 @@ export const dict = { "provider.connect.status.waiting": "等待授权...", "provider.connect.status.failed": "授权失败:{{error}}", "provider.connect.apiKey.description": - "输入你的 {{provider}} API 密钥以连接帐户,并在 OpenCode 中使用 {{provider}} 模型。", + "输入你的 {{provider}} API 密钥以连接帐户,并在 Kilo 中使用 {{provider}} 模型。", "provider.connect.apiKey.label": "{{provider}} API 密钥", "provider.connect.apiKey.placeholder": "API 密钥", "provider.connect.apiKey.required": "API 密钥为必填项", @@ -165,14 +165,14 @@ export const dict = { "provider.connect.opencodeZen.visit.suffix": " 获取你的 API 密钥。", "provider.connect.oauth.code.visit.prefix": "访问 ", "provider.connect.oauth.code.visit.link": "此链接", - "provider.connect.oauth.code.visit.suffix": " 获取授权码,以连接你的帐户并在 OpenCode 中使用 {{provider}} 模型。", + "provider.connect.oauth.code.visit.suffix": " 获取授权码,以连接你的帐户并在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.code.label": "{{method}} 授权码", "provider.connect.oauth.code.placeholder": "授权码", "provider.connect.oauth.code.required": "授权码为必填项", "provider.connect.oauth.code.invalid": "授权码无效", "provider.connect.oauth.auto.visit.prefix": "访问 ", "provider.connect.oauth.auto.visit.link": "此链接", - "provider.connect.oauth.auto.visit.suffix": " 并输入以下代码,以连接你的帐户并在 OpenCode 中使用 {{provider}} 模型。", + "provider.connect.oauth.auto.visit.suffix": " 并输入以下代码,以连接你的帐户并在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "确认码", "provider.connect.toast.connected.title": "{{provider}} 已连接", "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", @@ -327,7 +327,7 @@ export const dict = { "dialog.directory.empty": "未找到文件夹", "dialog.server.title": "服务器", - "dialog.server.description": "切换此应用连接的 OpenCode 服务器。", + "dialog.server.description": "切换此应用连接的 Kilo 服务器。", "dialog.server.search.placeholder": "搜索服务器", "dialog.server.empty": "暂无服务器", "dialog.server.add.title": "添加服务器", @@ -444,7 +444,7 @@ export const dict = { "toast.session.unshare.failed.description": "取消分享会话时发生错误", "toast.session.listFailed.title": "无法加载 {{project}} 的会话", "toast.update.title": "有可用更新", - "toast.update.description": "OpenCode 有新版本 ({{version}}) 可安装。", + "toast.update.description": "Kilo 有新版本 ({{version}}) 可安装。", "toast.update.action.installRestart": "安装并重启", "toast.update.action.notYet": "稍后", @@ -455,7 +455,7 @@ export const dict = { "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", - "error.page.report.prefix": "请将此错误报告给 OpenCode 团队", + "error.page.report.prefix": "请将此错误报告给 Kilo 团队", "error.page.report.discord": "在 Discord 上", "error.page.version": "版本:{{version}}", "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?", @@ -472,7 +472,7 @@ export const dict = { "error.chain.didYouMean": "你是不是想输入:{{suggestions}}", "error.chain.modelNotFound": "未找到模型:{{provider}}/{{model}}", "error.chain.checkConfig": "请检查你的配置 (opencode.json) 中的 provider/model 名称", - "error.chain.mcpFailed": 'MCP 服务器 "{{name}}" 启动失败。注意: OpenCode 暂不支持 MCP 认证。', + "error.chain.mcpFailed": 'MCP 服务器 "{{name}}" 启动失败。注意: Kilo 暂不支持 MCP 认证。', "error.chain.providerAuthFailed": "提供商认证失败({{provider}}):{{message}}", "error.chain.providerInitFailed": '无法初始化提供商 "{{provider}}"。请检查凭据和配置。', "error.chain.configJsonInvalid": "配置文件 {{path}} 不是有效的 JSON(C)", @@ -598,13 +598,13 @@ export const dict = { "sidebar.workspaces.enable": "启用工作区", "sidebar.workspaces.disable": "禁用工作区", "sidebar.gettingStarted.title": "入门", - "sidebar.gettingStarted.line1": "OpenCode 提供免费模型,你可以立即开始使用。", + "sidebar.gettingStarted.line1": "Kilo 提供免费模型,你可以立即开始使用。", "sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.project.recentSessions": "最近会话", "sidebar.project.viewAllSessions": "查看全部会话", "sidebar.project.clearNotifications": "清除通知", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "桌面", "settings.section.server": "服务器", @@ -614,7 +614,7 @@ export const dict = { "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL 集成", - "settings.desktop.wsl.description": "在 Windows 的 WSL 环境中运行 OpenCode 服务器。", + "settings.desktop.wsl.description": "在 Windows 的 WSL 环境中运行 Kilo 服务器。", "settings.general.section.appearance": "外观", "settings.general.section.notifications": "系统通知", @@ -623,13 +623,13 @@ export const dict = { "settings.general.section.feed": "动态", "settings.general.section.display": "显示", "settings.general.row.language.title": "语言", - "settings.general.row.language.description": "更改 OpenCode 的显示语言", + "settings.general.row.language.description": "更改 Kilo 的显示语言", "settings.general.row.appearance.title": "外观", - "settings.general.row.appearance.description": "自定义 OpenCode 在你的设备上的外观", + "settings.general.row.appearance.description": "自定义 Kilo 在你的设备上的外观", "settings.general.row.colorScheme.title": "配色方案", - "settings.general.row.colorScheme.description": "选择 OpenCode 跟随系统、浅色或深色主题", + "settings.general.row.colorScheme.description": "选择 Kilo 跟随系统、浅色或深色主题", "settings.general.row.theme.title": "主题", - "settings.general.row.theme.description": "自定义 OpenCode 的主题。", + "settings.general.row.theme.description": "自定义 Kilo 的主题。", "settings.general.row.font.title": "代码字体", "settings.general.row.font.description": "自定义代码块和终端使用的字体", "settings.general.row.uiFont.title": "界面字体", @@ -651,13 +651,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗", "settings.updates.row.startup.title": "启动时检查更新", - "settings.updates.row.startup.description": "在 OpenCode 启动时自动检查更新", + "settings.updates.row.startup.description": "在 Kilo 启动时自动检查更新", "settings.updates.row.check.title": "检查更新", "settings.updates.row.check.description": "手动检查更新并在有更新时安装", "settings.updates.action.checkNow": "立即检查", "settings.updates.action.checking": "正在检查...", "settings.updates.toast.latest.title": "已是最新版本", - "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。", + "settings.updates.toast.latest.description": "你正在使用最新版本的 Kilo。", "sound.option.none": "无", "sound.option.alert01": "警报 01", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4abdf5db574..dcd680c9b93 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -119,7 +119,7 @@ export const dict = { "dialog.model.manage.description": "自訂模型選擇器中顯示的模型。", "dialog.model.manage.provider.toggle": "切換所有 {{provider}} 模型", - "dialog.model.unpaid.freeModels.title": "OpenCode 提供的免費模型", + "dialog.model.unpaid.freeModels.title": "Kilo 提供的免費模型", "dialog.model.unpaid.addMore.title": "從熱門提供者新增更多模型", "dialog.provider.viewAll": "查看更多提供者", @@ -132,7 +132,7 @@ export const dict = { "provider.connect.status.waiting": "等待授權...", "provider.connect.status.failed": "授權失敗: {{error}}", "provider.connect.apiKey.description": - "輸入你的 {{provider}} API 金鑰以連線帳戶,並在 OpenCode 中使用 {{provider}} 模型。", + "輸入你的 {{provider}} API 金鑰以連線帳戶,並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.apiKey.label": "{{provider}} API 金鑰", "provider.connect.apiKey.placeholder": "API 金鑰", "provider.connect.apiKey.required": "API 金鑰為必填", @@ -143,7 +143,7 @@ export const dict = { "provider.connect.opencodeZen.visit.suffix": " 取得你的 API 金鑰。", "provider.connect.oauth.code.visit.prefix": "造訪 ", "provider.connect.oauth.code.visit.link": "此連結", - "provider.connect.oauth.code.visit.suffix": " 取得授權碼,以連線你的帳戶並在 OpenCode 中使用 {{provider}} 模型。", + "provider.connect.oauth.code.visit.suffix": " 取得授權碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.code.label": "{{method}} 授權碼", "provider.connect.oauth.code.placeholder": "授權碼", "provider.connect.oauth.code.required": "授權碼為必填", @@ -151,7 +151,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "造訪 ", "provider.connect.oauth.auto.visit.link": "此連結", "provider.connect.oauth.auto.visit.suffix": - " 並輸入以下程式碼,以連線你的帳戶並在 OpenCode 中使用 {{provider}} 模型。", + " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "確認碼", "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", @@ -307,7 +307,7 @@ export const dict = { "dialog.directory.empty": "找不到資料夾", "dialog.server.title": "伺服器", - "dialog.server.description": "切換此應用程式連線的 OpenCode 伺服器。", + "dialog.server.description": "切換此應用程式連線的 Kilo 伺服器。", "dialog.server.search.placeholder": "搜尋伺服器", "dialog.server.empty": "暫無伺服器", "dialog.server.add.title": "新增伺服器", @@ -437,7 +437,7 @@ export const dict = { "toast.session.listFailed.title": "無法載入 {{project}} 的工作階段", "toast.update.title": "有可用更新", - "toast.update.description": "OpenCode 有新版本 ({{version}}) 可安裝。", + "toast.update.description": "Kilo 有新版本 ({{version}}) 可安裝。", "toast.update.action.installRestart": "安裝並重新啟動", "toast.update.action.notYet": "稍後", @@ -448,7 +448,7 @@ export const dict = { "error.page.action.checking": "檢查中...", "error.page.action.checkUpdates": "檢查更新", "error.page.action.updateTo": "更新到 {{version}}", - "error.page.report.prefix": "請將此錯誤回報給 OpenCode 團隊", + "error.page.report.prefix": "請將此錯誤回報給 Kilo 團隊", "error.page.report.discord": "在 Discord 上", "error.page.version": "版本: {{version}}", @@ -466,7 +466,7 @@ export const dict = { "error.chain.didYouMean": "你是不是想輸入: {{suggestions}}", "error.chain.modelNotFound": "找不到模型: {{provider}}/{{model}}", "error.chain.checkConfig": "請檢查你的設定 (opencode.json) 中的 provider/model 名稱", - "error.chain.mcpFailed": 'MCP 伺服器 "{{name}}" 啟動失敗。注意: OpenCode 暫不支援 MCP 認證。', + "error.chain.mcpFailed": 'MCP 伺服器 "{{name}}" 啟動失敗。注意: Kilo 暫不支援 MCP 認證。', "error.chain.providerAuthFailed": "提供者認證失敗 ({{provider}}): {{message}}", "error.chain.providerInitFailed": '無法初始化提供者 "{{provider}}"。請檢查憑證和設定。', "error.chain.configJsonInvalid": "設定檔 {{path}} 不是有效的 JSON(C)", @@ -595,20 +595,20 @@ export const dict = { "sidebar.workspaces.enable": "啟用工作區", "sidebar.workspaces.disable": "停用工作區", "sidebar.gettingStarted.title": "開始使用", - "sidebar.gettingStarted.line1": "OpenCode 提供免費模型,你可以立即開始使用。", + "sidebar.gettingStarted.line1": "Kilo 提供免費模型,你可以立即開始使用。", "sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.project.recentSessions": "最近工作階段", "sidebar.project.viewAllSessions": "查看全部工作階段", "sidebar.project.clearNotifications": "清除通知", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "桌面", "settings.section.server": "伺服器", "settings.tab.general": "一般", "settings.tab.shortcuts": "快速鍵", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL 整合", - "settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 OpenCode 伺服器。", + "settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 Kilo 伺服器。", "settings.general.section.appearance": "外觀", "settings.general.section.notifications": "系統通知", @@ -618,13 +618,13 @@ export const dict = { "settings.general.section.display": "顯示", "settings.general.row.language.title": "語言", - "settings.general.row.language.description": "變更 OpenCode 的顯示語言", + "settings.general.row.language.description": "變更 Kilo 的顯示語言", "settings.general.row.appearance.title": "外觀", - "settings.general.row.appearance.description": "自訂 OpenCode 在你的裝置上的外觀", + "settings.general.row.appearance.description": "自訂 Kilo 在你的裝置上的外觀", "settings.general.row.colorScheme.title": "配色方案", - "settings.general.row.colorScheme.description": "選擇 OpenCode 要跟隨系統、淺色或深色主題", + "settings.general.row.colorScheme.description": "選擇 Kilo 要跟隨系統、淺色或深色主題", "settings.general.row.theme.title": "主題", - "settings.general.row.theme.description": "自訂 OpenCode 的主題。", + "settings.general.row.theme.description": "自訂 Kilo 的主題。", "settings.general.row.font.title": "程式碼字型", "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型", "settings.general.row.uiFont.title": "介面字型", @@ -648,13 +648,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "更新後顯示「新功能」彈出視窗", "settings.updates.row.startup.title": "啟動時檢查更新", - "settings.updates.row.startup.description": "在 OpenCode 啟動時自動檢查更新", + "settings.updates.row.startup.description": "在 Kilo 啟動時自動檢查更新", "settings.updates.row.check.title": "檢查更新", "settings.updates.row.check.description": "手動檢查更新並在有更新時安裝", "settings.updates.action.checkNow": "立即檢查", "settings.updates.action.checking": "檢查中...", "settings.updates.toast.latest.title": "已是最新版本", - "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。", + "settings.updates.toast.latest.description": "你正在使用最新版本的 Kilo。", "sound.option.none": "無", "sound.option.alert01": "警報 01", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 11284b3d2d7..9f12aa1cfcd 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -301,7 +301,7 @@ export const ErrorPage: Component = (props) => {