From 738d6c889956ec9924f9537d703764f116990bc7 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 1 Feb 2026 21:01:15 -0500 Subject: [PATCH 01/84] feat: save prompt to history when cleared with Ctrl+C When users press Ctrl+C to clear the input field, the current prompt is now saved to history before clearing. This allows users to navigate back to cleared prompts using arrow keys, preventing loss of work. Addresses #11489 --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 8576dd5763ab..9032b0940c4d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -832,6 +832,10 @@ export function Prompt(props: PromptProps) { // If no image, let the default paste behavior continue } if (keybind.match("input_clear", e) && store.prompt.input !== "") { + history.append({ + ...store.prompt, + mode: store.mode, + }) input.clear() input.extmarks.clear() setStore("prompt", { From 2c6ff354004bd6134ee0387c9691b21cd8b47519 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 1 Feb 2026 21:12:48 -0500 Subject: [PATCH 02/84] feat: add toggle to control whether cleared prompts are saved to history Adds a toggle command in the System category that allows users to enable or disable saving cleared prompts to history. The feature is disabled by default to preserve existing behavior. When enabled via the command palette ("Include cleared prompts in history"), pressing Ctrl+C will save the current prompt to history before clearing it, allowing users to navigate back with arrow keys. The setting persists in kv.json. --- packages/opencode/src/cli/cmd/tui/app.tsx | 9 +++++++++ .../src/cli/cmd/tui/component/prompt/index.tsx | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 713def2e5af4..12f68a15c5e2 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -582,6 +582,15 @@ function App() { dialog.clear() }, }, + { + title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history", + value: "app.toggle.clear_prompt_history", + category: "System", + onSelect: (dialog) => { + kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false)) + dialog.clear() + }, + }, ]) createEffect(() => { 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 9032b0940c4d..801a2364138b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -832,10 +832,12 @@ export function Prompt(props: PromptProps) { // If no image, let the default paste behavior continue } if (keybind.match("input_clear", e) && store.prompt.input !== "") { - history.append({ - ...store.prompt, - mode: store.mode, - }) + if (kv.get("clear_prompt_save_history", false)) { + history.append({ + ...store.prompt, + mode: store.mode, + }) + } input.clear() input.extmarks.clear() setStore("prompt", { From 878c1b8c2d2fadd3cf646e7ffea2489e334153fd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:41:42 -0500 Subject: [PATCH 03/84] feat(tui): add auto-accept mode for permission requests Add a toggleable auto-accept mode that automatically accepts all incoming permission requests with a 'once' reply. This is useful for users who want to streamline their workflow when they trust the agent's actions. Changes: - Add permission_auto_accept keybind (default: shift+tab) to config - Remove default for agent_cycle_reverse (was shift+tab) - Add auto-accept logic in sync.tsx to auto-reply when enabled - Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions") - Add visual indicator showing 'auto-accept' when active - Store auto-accept state in KV for persistence across sessions --- .../cli/cmd/tui/component/prompt/index.tsx | 50 +++++++++++++------ .../opencode/src/cli/cmd/tui/context/sync.tsx | 11 ++++ packages/opencode/src/config/config.ts | 7 ++- 3 files changed, 51 insertions(+), 17 deletions(-) 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 8576dd5763ab..8a08c3fc1378 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -74,6 +74,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const [autoaccept, setAutoaccept] = kv.signal("permission_auto_accept", false) function promptModelWarning() { toast.show({ @@ -157,6 +158,16 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ + { + title: "Toggle autoaccept permissions", + value: "permission.auto_accept.toggle", + keybind: "permission_auto_accept_toggle", + category: "Permission", + onSelect: (dialog) => { + setAutoaccept(!autoaccept() as any) + dialog.clear() + }, + }, { title: "Clear prompt", value: "prompt.clear", @@ -973,23 +984,30 @@ export function Prompt(props: PromptProps) { cursorColor={theme.text} syntaxStyle={syntax()} /> - - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - - {local.model.parsed().model} - - {local.model.parsed().provider} - - · - - {local.model.variant.current()} + + + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + + + + + {local.model.parsed().model} - - + {local.model.parsed().provider} + + · + + {local.model.variant.current()} + + + + + + + + auto-accept + diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bbad..2ad41d348254 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" +import { useKV } from "./kv" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" @@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const sdk = useSDK() + const kv = useKV() + const [autoaccept] = kv.signal("permission_auto_accept", false) sdk.event.listen((e) => { const event = e.details @@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "permission.asked": { const request = event.properties + if (autoaccept()) { + sdk.client.permission.reply({ + reply: "once", + requestID: request.id, + }) + break + } const requests = store.permission[request.sessionID] if (!requests) { setStore("permission", request.sessionID, [request]) @@ -423,6 +433,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ get ready() { return store.status !== "loading" }, + session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a5300724..1f6ca484ce38 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -818,7 +818,12 @@ export namespace Config { 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"), + agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"), + permission_auto_accept_toggle: z + .string() + .optional() + .default("shift+tab") + .describe("Toggle auto-accept mode for permissions"), variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle 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"), From 405cc3f610a16c28c521e049e6d0fdbd67e2cc35 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:51:55 -0500 Subject: [PATCH 04/84] tui: streamline permission toggle command naming and add keyboard shortcut support Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity and move the command to the Agent category for better discoverability. Add permission_auto_accept_toggle keybind to enable keyboard shortcut toggling of auto-accept mode for permission requests. --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) 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 8a08c3fc1378..b2cd177f146e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -159,10 +159,10 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: "Toggle autoaccept permissions", + title: "Toggle permissions", value: "permission.auto_accept.toggle", keybind: "permission_auto_accept_toggle", - category: "Permission", + category: "Agent", onSelect: (dialog) => { setAutoaccept(!autoaccept() as any) dialog.clear() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d72c37a28b5a..8740059607f0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1126,6 +1126,10 @@ export type KeybindsConfig = { * Previous agent */ agent_cycle_reverse?: string + /** + * Toggle auto-accept mode for permissions + */ + permission_auto_accept_toggle?: string /** * Cycle model variants */ From f202536b65b5a42a9533a527b697fc83ed7cd0c6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:57:48 -0500 Subject: [PATCH 05/84] tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 3 ++- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 b2cd177f146e..362a6c0b5f96 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -159,8 +159,9 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: "Toggle permissions", + title: autoaccept() ? "Disable permissions" : "Enable permissions", value: "permission.auto_accept.toggle", + search: "toggle permissions", keybind: "permission_auto_accept_toggle", category: "Agent", onSelect: (dialog) => { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 7792900bcfef..6ba0648086c3 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -33,6 +33,7 @@ export interface DialogSelectOption { title: string value: T description?: string + search?: string footer?: JSX.Element | string category?: string disabled?: boolean @@ -85,8 +86,8 @@ export function DialogSelect(props: DialogSelectProps) { // users typically search by the item name, and not its category. const result = fuzzysort .go(needle, options, { - keys: ["title", "category"], - scoreFn: (r) => r[0].score * 2 + r[1].score, + keys: ["title", "category", "search"], + scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score, }) .map((x) => x.obj) From ac244b1458f6092aa2303738e58e339e83e839e6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 17:03:34 -0500 Subject: [PATCH 06/84] tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles --- packages/opencode/src/cli/cmd/tui/app.tsx | 9 ++++++++- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 1 + .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 8 +++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc3b..5b1a85b6f5fa 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -415,6 +415,7 @@ function App() { { title: "Toggle MCPs", value: "mcp.list", + search: "toggle mcps", category: "Agent", slash: { name: "mcps", @@ -490,8 +491,9 @@ function App() { category: "System", }, { - title: "Toggle appearance", + title: mode() === "dark" ? "Light mode" : "Dark mode", value: "theme.switch_mode", + search: "toggle appearance", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() @@ -530,6 +532,7 @@ function App() { }, { title: "Toggle debug panel", + search: "toggle debug", category: "System", value: "app.debug", onSelect: (dialog) => { @@ -539,6 +542,7 @@ function App() { }, { title: "Toggle console", + search: "toggle console", category: "System", value: "app.console", onSelect: (dialog) => { @@ -579,6 +583,7 @@ function App() { { title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", value: "terminal.title.toggle", + search: "toggle terminal title", keybind: "terminal_title_toggle", category: "System", onSelect: (dialog) => { @@ -594,6 +599,7 @@ function App() { { title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", value: "app.toggle.animations", + search: "toggle animations", category: "System", onSelect: (dialog) => { kv.set("animations_enabled", !kv.get("animations_enabled", true)) @@ -603,6 +609,7 @@ function App() { { title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", value: "app.toggle.diffwrap", + search: "toggle diff wrapping", category: "System", onSelect: (dialog) => { const current = kv.get("diff_wrap_mode", "word") diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d94c..48ec24d0d555 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -46,6 +46,7 @@ export function Home() { { title: tipsHidden() ? "Show tips" : "Hide tips", value: "tips.toggle", + search: "toggle tips", keybind: "tips_toggle", category: "System", onSelect: (dialog) => { 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 77872eedaddd..70a038ffe368 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -509,6 +509,7 @@ export function Session() { { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", + search: "toggle sidebar", keybind: "sidebar_toggle", category: "Session", onSelect: (dialog) => { @@ -523,6 +524,7 @@ export function Session() { { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", + search: "toggle code concealment", keybind: "messages_toggle_conceal" as any, category: "Session", onSelect: (dialog) => { @@ -533,6 +535,7 @@ export function Session() { { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", + search: "toggle timestamps", category: "Session", slash: { name: "timestamps", @@ -546,6 +549,7 @@ export function Session() { { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", + search: "toggle thinking", keybind: "display_thinking", category: "Session", slash: { @@ -560,6 +564,7 @@ export function Session() { { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", + search: "toggle tool details", keybind: "tool_details", category: "Session", onSelect: (dialog) => { @@ -568,8 +573,9 @@ export function Session() { }, }, { - title: "Toggle session scrollbar", + title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar", value: "session.toggle.scrollbar", + search: "toggle session scrollbar", keybind: "scrollbar_toggle", category: "Session", onSelect: (dialog) => { From ad545d0cc9152675549f878f258aeff37f9e17e8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 19:52:53 -0500 Subject: [PATCH 07/84] tui: allow auto-accepting only edit permissions instead of all permissions --- packages/opencode/src/agent/agent.ts | 1 + .../src/cli/cmd/tui/component/prompt/index.tsx | 10 +++++----- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e338559be7e4..a091484f15ec 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -63,6 +63,7 @@ export namespace Agent { question: "deny", plan_enter: "deny", plan_exit: "deny", + edit: "ask", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", 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 362a6c0b5f96..a78ef1102287 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -74,7 +74,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() - const [autoaccept, setAutoaccept] = kv.signal("permission_auto_accept", false) + const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit") function promptModelWarning() { toast.show({ @@ -159,13 +159,13 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: autoaccept() ? "Disable permissions" : "Enable permissions", + title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit", value: "permission.auto_accept.toggle", search: "toggle permissions", keybind: "permission_auto_accept_toggle", category: "Agent", onSelect: (dialog) => { - setAutoaccept(!autoaccept() as any) + setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none")) dialog.clear() }, }, @@ -1005,9 +1005,9 @@ export function Prompt(props: PromptProps) { - + - auto-accept + auto-edit diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2ad41d348254..a51461125874 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -105,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const kv = useKV() - const [autoaccept] = kv.signal("permission_auto_accept", false) + const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit") sdk.event.listen((e) => { const event = e.details @@ -130,7 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "permission.asked": { const request = event.properties - if (autoaccept()) { + if (autoaccept() === "edit" && request.permission === "edit") { sdk.client.permission.reply({ reply: "once", requestID: request.id, From bb3382311d720a21c38186a540e7c12d1fd68a50 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 19:57:45 -0500 Subject: [PATCH 08/84] tui: standardize autoedit indicator text styling to match other status labels --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 2 +- 1 file changed, 1 insertion(+), 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 a78ef1102287..97e3d1052340 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1007,7 +1007,7 @@ export function Prompt(props: PromptProps) { - auto-edit + autoedit From a531f3f36d441cc39082fc5c9aaab64ac35f60ad Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 20:00:09 -0500 Subject: [PATCH 09/84] core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands --- packages/opencode/src/cli/cmd/run.ts | 5 +++++ packages/opencode/test/agent/agent.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 163a5820d99d..a7d6fa7f3926 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -350,6 +350,11 @@ export const RunCommand = cmd({ action: "deny", pattern: "*", }, + { + permission: "edit", + action: "allow", + pattern: "*", + }, ] function title() { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 5e91059ffb36..cde30f681ff0 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => { expect(build).toBeDefined() expect(build?.mode).toBe("primary") expect(build?.native).toBe(true) - expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "edit")).toBe("ask") expect(evalPerm(build, "bash")).toBe("allow") }, }) @@ -203,8 +203,8 @@ test("agent permission config merges with defaults", async () => { expect(build).toBeDefined() // Specific pattern is denied expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") - // Edit still allowed - expect(evalPerm(build, "edit")).toBe("allow") + // Edit still asks (default behavior) + expect(evalPerm(build, "edit")).toBe("ask") }, }) }) From 8805dfc8496858bf090cabe12157db1b75d142b4 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 10 Feb 2026 22:21:39 -0500 Subject: [PATCH 10/84] fix: deduplicate prompt history entries Avoid adding duplicate entries to prompt history when the same input is appended multiple times (e.g., clearing with ctrl+c then restoring via history navigation and clearing again). --- packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e90503e9f52e..d7bf1486146b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -82,6 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create return store.history.at(store.index) }, append(item: PromptInfo) { + if (store.history.at(-1)?.input === item.input) return const entry = clone(item) let trimmed = false setStore( From db039db7f54b0fabad1af73306c04a8a9a979cc8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 20 Mar 2026 10:21:10 -0400 Subject: [PATCH 11/84] regen js sdk --- packages/sdk/js/src/v2/gen/types.gen.ts | 386 ------------------------ 1 file changed, 386 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 20ecf168aa8d..ec797f2ba818 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1009,392 +1009,6 @@ export type GlobalEvent = { payload: Event } -/** - * Custom keybind configurations - */ -export type KeybindsConfig = { - /** - * Leader key for keybind combinations - */ - leader?: string - /** - * Exit the application - */ - app_exit?: string - /** - * Open external editor - */ - editor_open?: string - /** - * List available themes - */ - theme_list?: string - /** - * Toggle sidebar - */ - sidebar_toggle?: string - /** - * Toggle session scrollbar - */ - scrollbar_toggle?: string - /** - * Toggle username visibility - */ - username_toggle?: string - /** - * View status - */ - status_view?: string - /** - * Export session to editor - */ - session_export?: string - /** - * Create a new session - */ - session_new?: string - /** - * List all sessions - */ - session_list?: string - /** - * Show session timeline - */ - session_timeline?: string - /** - * Fork session from message - */ - session_fork?: string - /** - * Rename session - */ - session_rename?: string - /** - * Delete session - */ - session_delete?: string - /** - * Delete stash entry - */ - stash_delete?: string - /** - * Open provider list from model dialog - */ - model_provider_list?: string - /** - * Toggle model favorite status - */ - model_favorite_toggle?: string - /** - * Share current session - */ - session_share?: string - /** - * Unshare current session - */ - session_unshare?: string - /** - * Interrupt current session - */ - session_interrupt?: string - /** - * Compact the session - */ - session_compact?: string - /** - * Scroll messages up by one page - */ - messages_page_up?: string - /** - * Scroll messages down by one page - */ - messages_page_down?: string - /** - * Scroll messages up by one line - */ - messages_line_up?: string - /** - * Scroll messages down by one line - */ - messages_line_down?: string - /** - * Scroll messages up by half page - */ - messages_half_page_up?: string - /** - * Scroll messages down by half page - */ - messages_half_page_down?: string - /** - * Navigate to first message - */ - messages_first?: string - /** - * Navigate to last message - */ - messages_last?: string - /** - * Navigate to next message - */ - messages_next?: string - /** - * Navigate to previous message - */ - messages_previous?: string - /** - * Navigate to last user message - */ - messages_last_user?: string - /** - * Copy message - */ - messages_copy?: string - /** - * Undo message - */ - messages_undo?: string - /** - * Redo message - */ - messages_redo?: string - /** - * Toggle code block concealment in messages - */ - messages_toggle_conceal?: string - /** - * Toggle tool details visibility - */ - tool_details?: string - /** - * List available models - */ - model_list?: string - /** - * Next recently used model - */ - model_cycle_recent?: string - /** - * Previous recently used model - */ - model_cycle_recent_reverse?: string - /** - * Next favorite model - */ - model_cycle_favorite?: string - /** - * Previous favorite model - */ - model_cycle_favorite_reverse?: string - /** - * List available commands - */ - command_list?: string - /** - * List agents - */ - agent_list?: string - /** - * Next agent - */ - agent_cycle?: string - /** - * Previous agent - */ - agent_cycle_reverse?: string - /** - * Toggle auto-accept mode for permissions - */ - permission_auto_accept_toggle?: string - /** - * Cycle model variants - */ - variant_cycle?: string - /** - * Clear input field - */ - input_clear?: string - /** - * Paste from clipboard - */ - input_paste?: string - /** - * Submit input - */ - input_submit?: string - /** - * Insert newline in input - */ - input_newline?: string - /** - * Move cursor left in input - */ - input_move_left?: string - /** - * Move cursor right in input - */ - input_move_right?: string - /** - * Move cursor up in input - */ - input_move_up?: string - /** - * Move cursor down in input - */ - input_move_down?: string - /** - * Select left in input - */ - input_select_left?: string - /** - * Select right in input - */ - input_select_right?: string - /** - * Select up in input - */ - input_select_up?: string - /** - * Select down in input - */ - input_select_down?: string - /** - * Move to start of line in input - */ - input_line_home?: string - /** - * Move to end of line in input - */ - input_line_end?: string - /** - * Select to start of line in input - */ - input_select_line_home?: string - /** - * Select to end of line in input - */ - input_select_line_end?: string - /** - * Move to start of visual line in input - */ - input_visual_line_home?: string - /** - * Move to end of visual line in input - */ - input_visual_line_end?: string - /** - * Select to start of visual line in input - */ - input_select_visual_line_home?: string - /** - * Select to end of visual line in input - */ - input_select_visual_line_end?: string - /** - * Move to start of buffer in input - */ - input_buffer_home?: string - /** - * Move to end of buffer in input - */ - input_buffer_end?: string - /** - * Select to start of buffer in input - */ - input_select_buffer_home?: string - /** - * Select to end of buffer in input - */ - input_select_buffer_end?: string - /** - * Delete line in input - */ - input_delete_line?: string - /** - * Delete to end of line in input - */ - input_delete_to_line_end?: string - /** - * Delete to start of line in input - */ - input_delete_to_line_start?: string - /** - * Backspace in input - */ - input_backspace?: string - /** - * Delete character in input - */ - input_delete?: string - /** - * Undo in input - */ - input_undo?: string - /** - * Redo in input - */ - input_redo?: string - /** - * Move word forward in input - */ - input_word_forward?: string - /** - * Move word backward in input - */ - input_word_backward?: string - /** - * Select word forward in input - */ - input_select_word_forward?: string - /** - * Select word backward in input - */ - input_select_word_backward?: string - /** - * Delete word forward in input - */ - input_delete_word_forward?: string - /** - * Delete word backward in input - */ - input_delete_word_backward?: string - /** - * Previous history item - */ - history_previous?: string - /** - * Next history item - */ - history_next?: string - /** - * Next child session - */ - session_child_cycle?: string - /** - * Previous child session - */ - session_child_cycle_reverse?: string - /** - * Go to parent session - */ - session_parent?: string - /** - * Suspend terminal - */ - terminal_suspend?: string - /** - * Toggle terminal title - */ - terminal_title_toggle?: string - /** - * Toggle tips on home screen - */ - tips_toggle?: string - /** - * Toggle thinking blocks visibility - */ - display_thinking?: string -} - /** * Log level */ From 048ac63abd13a158e7dcca9d87a780e8e30b38ff Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:08:27 +1000 Subject: [PATCH 12/84] refactor: split monolithic bash tool into separate bash/pwsh/powershell tools --- packages/opencode/src/acp/agent.ts | 8 +- packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/cli/cmd/agent.ts | 15 +- packages/opencode/src/cli/cmd/run.ts | 5 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- packages/opencode/src/config/config.ts | 4 + packages/opencode/src/permission/arity.ts | 163 ------ packages/opencode/src/permission/index.ts | 30 ++ packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/tool/bash.ts | 500 ------------------ packages/opencode/src/tool/registry.ts | 10 +- packages/opencode/src/tool/shell/arity.ts | 157 ++++++ packages/opencode/src/tool/shell/bash.ts | 76 +++ packages/opencode/src/tool/shell/parser.ts | 203 +++++++ .../opencode/src/tool/shell/powershell.ts | 76 +++ packages/opencode/src/tool/shell/pwsh.ts | 76 +++ packages/opencode/src/tool/shell/runner.ts | 140 +++++ .../src/tool/{bash.txt => shell/shell.txt} | 28 +- packages/opencode/src/tool/shell/util.ts | 115 ++++ .../opencode/test/permission/arity.test.ts | 34 +- .../opencode/test/permission/next.test.ts | 14 +- .../test/tool/{bash.test.ts => shell.test.ts} | 118 +++-- 23 files changed, 1030 insertions(+), 756 deletions(-) delete mode 100644 packages/opencode/src/permission/arity.ts delete mode 100644 packages/opencode/src/tool/bash.ts create mode 100644 packages/opencode/src/tool/shell/arity.ts create mode 100644 packages/opencode/src/tool/shell/bash.ts create mode 100644 packages/opencode/src/tool/shell/parser.ts create mode 100644 packages/opencode/src/tool/shell/powershell.ts create mode 100644 packages/opencode/src/tool/shell/pwsh.ts create mode 100644 packages/opencode/src/tool/shell/runner.ts rename packages/opencode/src/tool/{bash.txt => shell/shell.txt} (77%) create mode 100644 packages/opencode/src/tool/shell/util.ts rename packages/opencode/test/tool/{bash.test.ts => shell.test.ts} (92%) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e444..ddcdebeceb45 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -285,7 +285,7 @@ export namespace ACP { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") { if (this.bashSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1081,7 +1081,7 @@ export namespace ACP { } private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + if (part.tool !== "bash" && part.tool !== "pwsh" && part.tool !== "powershell") return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1484,6 +1484,8 @@ export namespace ACP { const tool = toolName.toLocaleLowerCase() switch (tool) { case "bash": + case "pwsh": + case "powershell": return "execute" case "webfetch": return "fetch" @@ -1519,6 +1521,8 @@ export namespace ACP { case "grep": return input["path"] ? [{ path: input["path"] }] : [] case "bash": + case "pwsh": + case "powershell": return [] case "list": return input["path"] ? [{ path: input["path"] }] : [] diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 53c655d1b373..7ce4daf8db39 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -167,6 +167,8 @@ export namespace Agent { glob: "allow", list: "allow", bash: "allow", + pwsh: "allow", + powershell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e75..a8e73c90b756 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,20 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = [ + "bash", + "pwsh", + "powershell", + "read", + "write", + "edit", + "list", + "glob", + "grep", + "webfetch", + "task", + "todowrite", +] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e8679..615cbf6494f9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,7 +24,7 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { BashTool } from "../../tool/shell/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -411,7 +411,8 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") + return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) 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 fb62de9acf5f..14282e825a71 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -35,7 +35,7 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { BashTool } from "@/tool/shell/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1514,7 +1514,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc843..e8a5b4c0ef2e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -280,7 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === "bash" || permission === "pwsh" || permission === "powershell") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbb3416233d..f1374e068654 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -639,6 +639,10 @@ export namespace Config { // write, edit, patch, multiedit all map to edit permission if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action + } else if (tool === "bash") { + permission.bash = action + permission.pwsh = action + permission.powershell = action } else { permission[tool] = action } diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts deleted file mode 100644 index 948841c8e799..000000000000 --- a/packages/opencode/src/permission/arity.ts +++ /dev/null @@ -1,163 +0,0 @@ -export namespace BashArity { - export function prefix(tokens: string[]) { - for (let len = tokens.length; len > 0; len--) { - const prefix = tokens.slice(0, len).join(" ") - const arity = ARITY[prefix] - if (arity !== undefined) return tokens.slice(0, arity) - } - if (tokens.length === 0) return [] - return tokens.slice(0, 1) - } - - /* Generated with following prompt: -You are generating a dictionary of command-prefix arities for bash-style commands. -This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. -2. **Flags NEVER count as tokens**. Only subcommands count. -3. **Longest matching prefix wins**. -4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. -5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical -6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) -* `git checkout main` → `git checkout` (because `git` has arity 2) -* `npm install` → `npm install` (because `npm` has arity 2) -* `npm run dev` → `npm run dev` (because `npm run` has arity 3) -* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** -*/ - const ARITY: Record = { - cat: 1, // cat file.txt - cd: 1, // cd /path/to/dir - chmod: 1, // chmod 755 script.sh - chown: 1, // chown user:group file.txt - cp: 1, // cp source.txt dest.txt - echo: 1, // echo "hello world" - env: 1, // env - export: 1, // export PATH=/usr/bin - grep: 1, // grep pattern file.txt - kill: 1, // kill 1234 - killall: 1, // killall process - ln: 1, // ln -s source target - ls: 1, // ls -la - mkdir: 1, // mkdir new-dir - mv: 1, // mv old.txt new.txt - ps: 1, // ps aux - pwd: 1, // pwd - rm: 1, // rm file.txt - rmdir: 1, // rmdir empty-dir - sleep: 1, // sleep 5 - source: 1, // source ~/.bashrc - tail: 1, // tail -f log.txt - touch: 1, // touch file.txt - unset: 1, // unset VAR - which: 1, // which node - aws: 3, // aws s3 ls - az: 3, // az storage blob list - bazel: 2, // bazel build - brew: 2, // brew install node - bun: 2, // bun install - "bun run": 3, // bun run dev - "bun x": 3, // bun x vite - cargo: 2, // cargo build - "cargo add": 3, // cargo add tokio - "cargo run": 3, // cargo run main - cdk: 2, // cdk deploy - cf: 2, // cf push app - cmake: 2, // cmake build - composer: 2, // composer require laravel - consul: 2, // consul members - "consul kv": 3, // consul kv get config/app - crictl: 2, // crictl ps - deno: 2, // deno run server.ts - "deno task": 3, // deno task dev - doctl: 3, // doctl kubernetes cluster list - docker: 2, // docker run nginx - "docker builder": 3, // docker builder prune - "docker compose": 3, // docker compose up - "docker container": 3, // docker container ls - "docker image": 3, // docker image prune - "docker network": 3, // docker network inspect - "docker volume": 3, // docker volume ls - eksctl: 2, // eksctl get clusters - "eksctl create": 3, // eksctl create cluster - firebase: 2, // firebase deploy - flyctl: 2, // flyctl deploy - gcloud: 3, // gcloud compute instances list - gh: 3, // gh pr list - git: 2, // git checkout main - "git config": 3, // git config user.name - "git remote": 3, // git remote add origin - "git stash": 3, // git stash pop - go: 2, // go build - gradle: 2, // gradle build - helm: 2, // helm install mychart - heroku: 2, // heroku logs - hugo: 2, // hugo new site blog - ip: 2, // ip link show - "ip addr": 3, // ip addr show - "ip link": 3, // ip link set eth0 up - "ip netns": 3, // ip netns exec foo bash - "ip route": 3, // ip route add default via 1.1.1.1 - kind: 2, // kind delete cluster - "kind create": 3, // kind create cluster - kubectl: 2, // kubectl get pods - "kubectl kustomize": 3, // kubectl kustomize overlays/dev - "kubectl rollout": 3, // kubectl rollout restart deploy/api - kustomize: 2, // kustomize build . - make: 2, // make build - mc: 2, // mc ls myminio - "mc admin": 3, // mc admin info myminio - minikube: 2, // minikube start - mongosh: 2, // mongosh test - mysql: 2, // mysql -u root - mvn: 2, // mvn compile - ng: 2, // ng generate component home - npm: 2, // npm install - "npm exec": 3, // npm exec vite - "npm init": 3, // npm init vue - "npm run": 3, // npm run dev - "npm view": 3, // npm view react version - nvm: 2, // nvm use 18 - nx: 2, // nx build - openssl: 2, // openssl genrsa 2048 - "openssl req": 3, // openssl req -new -key key.pem - "openssl x509": 3, // openssl x509 -in cert.pem - pip: 2, // pip install numpy - pipenv: 2, // pipenv install flask - pnpm: 2, // pnpm install - "pnpm dlx": 3, // pnpm dlx create-next-app - "pnpm exec": 3, // pnpm exec vite - "pnpm run": 3, // pnpm run dev - poetry: 2, // poetry add requests - podman: 2, // podman run alpine - "podman container": 3, // podman container ls - "podman image": 3, // podman image prune - psql: 2, // psql -d mydb - pulumi: 2, // pulumi up - "pulumi stack": 3, // pulumi stack output - pyenv: 2, // pyenv install 3.11 - python: 2, // python -m venv env - rake: 2, // rake db:migrate - rbenv: 2, // rbenv install 3.2.0 - "redis-cli": 2, // redis-cli ping - rustup: 2, // rustup update - serverless: 2, // serverless invoke - sfdx: 3, // sfdx force:org:list - skaffold: 2, // skaffold dev - sls: 2, // sls deploy - sst: 2, // sst deploy - swift: 2, // swift build - systemctl: 2, // systemctl restart nginx - terraform: 2, // terraform apply - "terraform workspace": 3, // terraform workspace select prod - tmux: 2, // tmux new -s dev - turbo: 2, // turbo run build - ufw: 2, // ufw allow 22 - vault: 2, // vault login - "vault auth": 3, // vault auth list - "vault kv": 3, // vault kv get secret/api - vercel: 2, // vercel deploy - volta: 2, // volta install node - wp: 2, // wp plugin install - yarn: 2, // yarn add react - "yarn dlx": 3, // yarn dlx create-react-app - "yarn run": 3, // yarn run dev - } -} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610a5..c4909566adbd 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -278,6 +278,36 @@ export namespace Permission { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] for (const [key, value] of Object.entries(permission)) { + if (key === "bash") { + if (typeof value === "string") { + ruleset.push({ permission: "bash", action: value, pattern: "*" }) + ruleset.push({ permission: "pwsh", action: value, pattern: "*" }) + ruleset.push({ permission: "powershell", action: value, pattern: "*" }) + } else { + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "bash", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "pwsh", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "powershell", + pattern: expand(pattern), + action, + })), + ) + } + continue + } if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a9edf838ca8c..ad265b1483a5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1629,12 +1629,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } await Session.updateMessage(msg) + const shell = Shell.preferred() + const shellName = Shell.name(shell) const part: MessageV2.Part = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: shellName === "pwsh" ? "pwsh" : shellName === "powershell" ? "powershell" : "bash", callID: ulid(), state: { status: "running", @@ -1647,8 +1649,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } await Session.updatePart(part) - const shell = Shell.preferred() - const shellName = Shell.name(shell) const invocations: Record = { nu: { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts deleted file mode 100644 index 50aa9e14ad76..000000000000 --- a/packages/opencode/src/tool/bash.ts +++ /dev/null @@ -1,500 +0,0 @@ -import z from "zod" -import os from "os" -import { spawn } from "child_process" -import { Tool } from "./tool" -import path from "path" -import DESCRIPTION from "./bash.txt" -import { Log } from "../util/log" -import { Instance } from "../project/instance" -import { lazy } from "@/util/lazy" -import { Language, type Node } from "web-tree-sitter" - -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" -import { fileURLToPath } from "url" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" - -import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" -import { Plugin } from "@/plugin" - -const MAX_METADATA_LENGTH = 30_000 -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const PS = new Set(["powershell", "pwsh"]) -const CWD = new Set(["cd", "push-location", "set-location"]) -const FILES = new Set([ - ...CWD, - "rm", - "cp", - "mv", - "mkdir", - "touch", - "chmod", - "chown", - "cat", - // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir - // already hit the entries above, and alias normalization should happen in one - // place later so we do not risk double-prompting. - "get-content", - "set-content", - "add-content", - "copy-item", - "move-item", - "remove-item", - "new-item", - "rename-item", -]) -const FLAGS = new Set(["-destination", "-literalpath", "-path"]) -const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) - -type Part = { - type: string - text: string -} - -type Scan = { - dirs: Set - patterns: Set - always: Set -} - -export const log = Log.create({ service: "bash-tool" }) - -const resolveWasm = (asset: string) => { - if (asset.startsWith("file://")) return fileURLToPath(asset) - if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset - const url = new URL(asset, import.meta.url) - return fileURLToPath(url) -} - -function parts(node: Node) { - const out: Part[] = [] - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i) - if (!child) continue - if (child.type === "command_elements") { - for (let j = 0; j < child.childCount; j++) { - const item = child.child(j) - if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue - out.push({ type: item.type, text: item.text }) - } - continue - } - if ( - child.type !== "command_name" && - child.type !== "command_name_expr" && - child.type !== "word" && - child.type !== "string" && - child.type !== "raw_string" && - child.type !== "concatenation" - ) { - continue - } - out.push({ type: child.type, text: child.text }) - } - return out -} - -function source(node: Node) { - return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() -} - -function commands(node: Node) { - return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) -} - -function unquote(text: string) { - if (text.length < 2) return text - const first = text[0] - const last = text[text.length - 1] - if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) - return text -} - -function home(text: string) { - if (text === "~") return os.homedir() - if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) - return text -} - -function envValue(key: string) { - if (process.platform !== "win32") return process.env[key] - const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) - return name ? process.env[name] : undefined -} - -function auto(key: string, cwd: string, shell: string) { - const name = key.toUpperCase() - if (name === "HOME") return os.homedir() - if (name === "PWD") return cwd - if (name === "PSHOME") return path.dirname(shell) -} - -function expand(text: string, cwd: string, shell: string) { - const out = unquote(text) - .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") - .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") - .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") - return home(out) -} - -function provider(text: string) { - const match = text.match(/^([A-Za-z]+)::(.*)$/) - if (match) { - if (match[1].toLowerCase() !== "filesystem") return - return match[2] - } - const prefix = text.match(/^([A-Za-z]+):(.*)$/) - if (!prefix) return text - if (prefix[1].length === 1) return text - return -} - -function dynamic(text: string, ps: boolean) { - if (text.startsWith("(") || text.startsWith("@(")) return true - if (text.includes("$(") || text.includes("${") || text.includes("`")) return true - if (ps) return /\$(?!env:)/i.test(text) - return text.includes("$") -} - -function prefix(text: string) { - const match = /[?*\[]/.exec(text) - if (!match) return text - if (match.index === 0) return - return text.slice(0, match.index) -} - -async function cygpath(shell: string, text: string) { - const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) - if (out.code !== 0) return - const file = out.text.trim() - if (!file) return - return Filesystem.normalizePath(file) -} - -async function resolvePath(text: string, root: string, shell: string) { - if (process.platform === "win32") { - if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { - const file = await cygpath(shell, text) - if (file) return file - } - return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) - } - return path.resolve(root, text) -} - -async function argPath(arg: string, cwd: string, ps: boolean, shell: string) { - const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) - const file = text && prefix(text) - if (!file || dynamic(file, ps)) return - const next = ps ? provider(file) : file - if (!next) return - return resolvePath(next, cwd, shell) -} - -function pathArgs(list: Part[], ps: boolean) { - if (!ps) { - return list - .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) - .map((item) => item.text) - } - - const out: string[] = [] - let want = false - for (const item of list.slice(1)) { - if (want) { - out.push(item.text) - want = false - continue - } - if (item.type === "command_parameter") { - const flag = item.text.toLowerCase() - if (SWITCHES.has(flag)) continue - want = FLAGS.has(flag) - continue - } - out.push(item.text) - } - return out -} - -async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise { - const scan: Scan = { - dirs: new Set(), - patterns: new Set(), - always: new Set(), - } - - for (const node of commands(root)) { - const command = parts(node) - const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] - - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { - const resolved = await argPath(arg, cwd, ps, shell) - log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved)) continue - const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) - scan.dirs.add(dir) - } - } - - if (tokens.length && (!cmd || !CWD.has(cmd))) { - scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") - } - } - - return scan -} - -function preview(text: string) { - if (text.length <= MAX_METADATA_LENGTH) return text - return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." -} - -async function parse(command: string, ps: boolean) { - const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) - if (!tree) throw new Error("Failed to parse command") - return tree.rootNode -} - -async function ask(ctx: Tool.Context, scan: Scan) { - if (scan.dirs.size > 0) { - const globs = Array.from(scan.dirs).map((dir) => { - if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) - return path.join(dir, "*") - }) - await ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }) - } - - if (scan.patterns.size === 0) return - await ctx.ask({ - permission: "bash", - patterns: Array.from(scan.patterns), - always: Array.from(scan.always), - metadata: {}, - }) -} - -async function shellEnv(ctx: Tool.Context, cwd: string) { - const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) - return { - ...process.env, - ...extra.env, - } -} - -function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && PS.has(name)) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - windowsHide: true, - }) - } - - return spawn(command, { - shell, - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - windowsHide: process.platform === "win32", - }) -} - -async function run( - input: { - shell: string - name: string - command: string - cwd: string - env: NodeJS.ProcessEnv - timeout: number - description: string - }, - ctx: Tool.Context, -) { - const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) - let output = "" - - ctx.metadata({ - metadata: { - output: "", - description: input.description, - }, - }) - - const append = (chunk: Buffer) => { - output += chunk.toString() - ctx.metadata({ - metadata: { - output: preview(output), - description: input.description, - }, - }) - } - - proc.stdout?.on("data", append) - proc.stderr?.on("data", append) - - let expired = false - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (ctx.abort.aborted) { - aborted = true - await kill() - } - - const abort = () => { - aborted = true - void kill() - } - - ctx.abort.addEventListener("abort", abort, { once: true }) - const timer = setTimeout(() => { - expired = true - void kill() - }, input.timeout + 100) - - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer) - ctx.abort.removeEventListener("abort", abort) - } - - proc.once("exit", () => { - exited = true - }) - - proc.once("close", () => { - exited = true - cleanup() - resolve() - }) - - proc.once("error", (error) => { - exited = true - cleanup() - reject(error) - }) - }) - - const metadata: string[] = [] - if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) - if (aborted) metadata.push("User aborted the command") - if (metadata.length > 0) { - output += "\n\n\n" + metadata.join("\n") + "\n" - } - - return { - title: input.description, - metadata: { - output: preview(output), - exit: proc.exitCode, - description: input.description, - }, - output, - } -} - -const parser = lazy(async () => { - const { Parser } = await import("web-tree-sitter") - const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { - with: { type: "wasm" }, - }) - const treePath = resolveWasm(treeWasm) - await Parser.init({ - locateFile() { - return treePath - }, - }) - const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { - with: { type: "wasm" }, - }) - const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { - with: { type: "wasm" }, - }) - const bashPath = resolveWasm(bashWasm) - const psPath = resolveWasm(psWasm) - const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)]) - const bash = new Parser() - bash.setLanguage(bashLanguage) - const ps = new Parser() - ps.setLanguage(psLanguage) - return { bash, ps } -}) - -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define("bash", async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - - return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - const ps = PS.has(name) - const root = await parse(params.command, ps) - const scan = await collect(root, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await ask(ctx, scan) - - return run( - { - shell, - name, - command: params.command, - cwd, - env: await shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eeb7334806e2..8c187cdbfce2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,5 @@ import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" -import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -32,6 +31,10 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { BashTool } from "./shell/bash" +import { PwshTool } from "./shell/pwsh" +import { PowershellTool } from "./shell/powershell" +import { Shell } from "@/shell/shell" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -115,10 +118,13 @@ export namespace ToolRegistry { const cfg = yield* config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const shellName = Shell.name(Shell.acceptable()) + const ActiveShellTool = shellName === "pwsh" ? PwshTool : shellName === "powershell" ? PowershellTool : BashTool + return [ InvalidTool, ...(question ? [QuestionTool] : []), - BashTool, + ActiveShellTool, ReadTool, GlobTool, GrepTool, diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts new file mode 100644 index 000000000000..3ec40b664619 --- /dev/null +++ b/packages/opencode/src/tool/shell/arity.ts @@ -0,0 +1,157 @@ +export namespace ShellArity { + export function prefix(tokens: string[], shellType: "bash" | "pwsh" | "powershell") { + if ( + (shellType === "pwsh" || shellType === "powershell") && + tokens.length > 0 && + /^[a-z]+-[a-z]+$/i.test(tokens[0]) + ) { + return [tokens[0]] + } + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) + } + + const ARITY: Record = { + cat: 1, + cd: 1, + chmod: 1, + chown: 1, + cp: 1, + echo: 1, + env: 1, + export: 1, + grep: 1, + kill: 1, + killall: 1, + ln: 1, + ls: 1, + mkdir: 1, + mv: 1, + ps: 1, + pwd: 1, + rm: 1, + rmdir: 1, + sleep: 1, + source: 1, + tail: 1, + touch: 1, + unset: 1, + which: 1, + aws: 3, + az: 3, + bazel: 2, + brew: 2, + bun: 2, + "bun run": 3, + "bun x": 3, + cargo: 2, + "cargo add": 3, + "cargo run": 3, + cdk: 2, + cf: 2, + cmake: 2, + composer: 2, + consul: 2, + "consul kv": 3, + crictl: 2, + deno: 2, + "deno task": 3, + doctl: 3, + docker: 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + eksctl: 2, + "eksctl create": 3, + firebase: 2, + flyctl: 2, + gcloud: 3, + gh: 3, + git: 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + go: 2, + gradle: 2, + helm: 2, + heroku: 2, + hugo: 2, + ip: 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + kind: 2, + "kind create": 3, + kubectl: 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + kustomize: 2, + make: 2, + mc: 2, + "mc admin": 3, + minikube: 2, + mongosh: 2, + mysql: 2, + mvn: 2, + ng: 2, + npm: 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + nvm: 2, + nx: 2, + openssl: 2, + "openssl req": 3, + "openssl x509": 3, + pip: 2, + pipenv: 2, + pnpm: 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + poetry: 2, + podman: 2, + "podman container": 3, + "podman image": 3, + psql: 2, + pulumi: 2, + "pulumi stack": 3, + pyenv: 2, + python: 2, + rake: 2, + rbenv: 2, + "redis-cli": 2, + rustup: 2, + serverless: 2, + sfdx: 3, + skaffold: 2, + sls: 2, + sst: 2, + swift: 2, + systemctl: 2, + terraform: 2, + "terraform workspace": 3, + tmux: 2, + turbo: 2, + ufw: 2, + vault: 2, + "vault auth": 3, + "vault kv": 3, + vercel: 2, + volta: 2, + wp: 2, + yarn: 2, + "yarn dlx": 3, + "yarn run": 3, + } +} diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts new file mode 100644 index 000000000000..98e05c82d6b3 --- /dev/null +++ b/packages/opencode/src/tool/shell/bash.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "bash-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "bash" + +export const BashTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("bash tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "Bash", + chaining: + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + }), + parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), + async execute(params, ctx) { + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + + const scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts new file mode 100644 index 000000000000..c1507553eb94 --- /dev/null +++ b/packages/opencode/src/tool/shell/parser.ts @@ -0,0 +1,203 @@ +import { Language, Parser, type Node } from "web-tree-sitter" +import { lazy } from "@/util/lazy" +import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" +import { Instance } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import path from "path" +import { ShellArity } from "./arity" +import { Log } from "@/util/log" + +const log = Log.create({ service: "shell-parser" }) + +const CWD = new Set(["cd", "push-location", "set-location"]) +const FILES_BASE = new Set(["rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"]) +const FILES_PWSH = new Set([ + "get-content", + "set-content", + "add-content", + "copy-item", + "move-item", + "remove-item", + "new-item", + "rename-item", +]) + +const FLAGS = new Set(["-destination", "-literalpath", "-path"]) +const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) + +function parts(node: Node) { + const out: Part[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if (child.type === "command_elements") { + for (let j = 0; j < child.childCount; j++) { + const item = child.child(j) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue + out.push({ type: item.type, text: item.text }) + } + continue + } + if ( + child.type !== "command_name" && + child.type !== "command_name_expr" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) { + continue + } + out.push({ type: child.type, text: child.text }) + } + return out +} + +function source(node: Node) { + return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() +} + +function commands(node: Node) { + return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) +} + +function dynamic(text: string, isPwsh: boolean) { + if (text.startsWith("(") || text.startsWith("@(")) return true + if (text.includes("$(") || text.includes("${") || text.includes("`")) return true + if (isPwsh) return /\$(?!env:)/i.test(text) + return text.includes("$") +} + +function prefix(text: string) { + const match = /[?*\[]/.exec(text) + if (!match) return text + if (match.index === 0) return + return text.slice(0, match.index) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const pre = text.match(/^([A-Za-z]+):(.*)$/) + if (!pre) return text + if (pre[1].length === 1) return text + return +} + +async function argPath(arg: string, cwd: string, shell: string, isPwsh: boolean) { + const text = isPwsh ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, isPwsh)) return + const next = isPwsh ? provider(file) : file + if (!next) return + return resolvePath(next, cwd, shell) +} + +function pathArgs(list: Part[], isPwsh: boolean) { + if (!isPwsh) { + return list + .slice(1) + .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .map((item) => item.text) + } + + const out: string[] = [] + let want = false + for (const item of list.slice(1)) { + if (want) { + out.push(item.text) + want = false + continue + } + if (item.type === "command_parameter") { + const flag = item.text.toLowerCase() + if (SWITCHES.has(flag)) continue + want = FLAGS.has(flag) + continue + } + out.push(item.text) + } + return out +} + +export namespace ShellParser { + const getParser = lazy(async () => { + const { Parser } = await import("web-tree-sitter") + const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { + with: { type: "wasm" }, + }) + const treePath = resolveWasm(treeWasm) + await Parser.init({ + locateFile() { + return treePath + }, + }) + const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { + with: { type: "wasm" }, + }) + const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { + with: { type: "wasm" }, + }) + const bashPath = resolveWasm(bashWasm) + const psPath = resolveWasm(psWasm) + const bashLanguage = await Language.load(bashPath) + const psLanguage = await Language.load(psPath) + + const bash = new Parser() + bash.setLanguage(bashLanguage) + + const ps = new Parser() + ps.setLanguage(psLanguage) + + return { bash, ps } + }) + + export async function collect(opts: { + command: string + cwd: string + shell: string + shellType: "bash" | "pwsh" | "powershell" + }): Promise { + const isPwsh = opts.shellType === "pwsh" || opts.shellType === "powershell" + const parsers = await getParser() + const parser = isPwsh ? parsers.ps : parsers.bash + + const tree = parser.parse(opts.command) + if (!tree) throw new Error("Failed to parse command") + const root = tree.rootNode + + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + const filesSet = new Set([...CWD, ...FILES_BASE, ...(isPwsh ? FILES_PWSH : [])]) + + for (const node of commands(root)) { + const commandParts = parts(node) + const tokens = commandParts.map((item) => item.text) + const cmd = isPwsh ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && filesSet.has(cmd)) { + for (const arg of pathArgs(commandParts, isPwsh)) { + const resolved = await argPath(arg, opts.cwd, opts.shell, isPwsh) + log.info("resolved path", { arg, resolved }) + if (!resolved || Instance.containsPath(resolved)) continue + const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + } + } + + if (tokens.length && (!cmd || !CWD.has(cmd))) { + scan.patterns.add(source(node)) + scan.always.add(ShellArity.prefix(tokens, opts.shellType).join(" ") + " *") + } + } + + return scan + } +} diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts new file mode 100644 index 000000000000..cc63ac2c029f --- /dev/null +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "powershell-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "powershell" + +export const PowershellTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("powershell tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "Windows PowerShell", + chaining: + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", + }), + parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), + async execute(params, ctx) { + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + + const scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts new file mode 100644 index 000000000000..525c9d4be34a --- /dev/null +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "pwsh-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "pwsh" + +export const PwshTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("pwsh tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "PowerShell Core", + chaining: + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + }), + parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), + async execute(params, ctx) { + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + + const scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts new file mode 100644 index 000000000000..057604076409 --- /dev/null +++ b/packages/opencode/src/tool/shell/runner.ts @@ -0,0 +1,140 @@ +import { spawn } from "child_process" +import { Shell } from "@/shell/shell" +import { Tool } from "../tool" +import { Plugin } from "@/plugin" + +const MAX_METADATA_LENGTH = 30_000 + +export function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." +} + +export namespace ShellRunner { + export async function shellEnv(ctx: Tool.Context, cwd: string) { + const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) + return { + ...process.env, + ...extra.env, + } + } + + export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + windowsHide: true, + }) + } + + return spawn(command, { + shell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: process.platform === "win32", + }) + } + + export async function run( + input: { + shell: string + name: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + description: string + }, + ctx: Tool.Context, + ) { + const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) + let output = "" + + ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + + const append = (chunk: Buffer) => { + output += chunk.toString() + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + } + + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) + + let expired = false + let aborted = false + let exited = false + + const kill = () => Shell.killTree(proc, { exited: () => exited }) + + if (ctx.abort.aborted) { + aborted = true + await kill() + } + + const abort = () => { + aborted = true + void kill() + } + + ctx.abort.addEventListener("abort", abort, { once: true }) + const timer = setTimeout(() => { + expired = true + void kill() + }, input.timeout + 100) + + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer) + ctx.abort.removeEventListener("abort", abort) + } + + proc.once("exit", () => { + exited = true + }) + + proc.once("close", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + const metadata: string[] = [] + if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (aborted) metadata.push("User aborted the command") + if (metadata.length > 0) { + output += "\n\n\n" + metadata.join("\n") + "\n" + } + + return { + title: input.description, + metadata: { + output: preview(output), + exit: proc.exitCode, + description: input.description, + }, + output, + } + } +} diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 77% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index 8d53c90ab4e8..7ef2ca73fb08 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,16 +1,16 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +Executes a given shell command in a persistent ${shellName} session with optional timeout, ensuring proper handling and security measures. Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +All commands run in ${directory} by default. Use the \`workdir\` parameter if you need to run a command in a different directory. AVOID using \`cd && \` patterns - use \`workdir\` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") @@ -26,9 +26,9 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) - Read files: Use Read (NOT cat/head/tail) @@ -36,11 +36,11 @@ Usage notes: - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. + - AVOID using \`cd && \`. Use the \`workdir\` parameter to change directories instead. Use workdir="/foo/bar" with command: pytest tests @@ -65,7 +65,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -89,15 +89,15 @@ Important notes: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote - - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) + - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed @@ -114,4 +114,4 @@ Important: - Return the PR URL when you're done, so the user can see it # Other common operations -- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments +- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments \ No newline at end of file diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts new file mode 100644 index 000000000000..0091ff8e8ce3 --- /dev/null +++ b/packages/opencode/src/tool/shell/util.ts @@ -0,0 +1,115 @@ +import path from "path" +import os from "os" +import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" +import { Shell } from "@/shell/shell" +import { Truncate } from "../truncate" +import { Instance } from "@/project/instance" +import { Tool } from "../tool" +import { fileURLToPath } from "url" + +export type Part = { + type: string + text: string +} + +export type Scan = { + dirs: Set + patterns: Set + always: Set +} + +export function resolveWasm(asset: string) { + if (asset.startsWith("file://")) return fileURLToPath(asset) + if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset + const url = new URL(asset, import.meta.url) + return fileURLToPath(url) +} + +export function unquote(text: string) { + if (text.length < 2) return text + const first = text[0] + const last = text[text.length - 1] + if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) + return text +} + +export function home(text: string) { + if (text === "~") return os.homedir() + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) + return text +} + +export function envValue(key: string) { + if (process.platform !== "win32") return process.env[key] + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} + +export function auto(key: string, cwd: string, shell: string) { + const name = key.toUpperCase() + if (name === "HOME") return os.homedir() + if (name === "PWD") return cwd + if (name === "PSHOME") return path.dirname(shell) +} + +export function expand(text: string, cwd: string, shell: string) { + const out = unquote(text) + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") + return home(out) +} + +export async function cygpath(shell: string, text: string) { + const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) + if (out.code !== 0) return + const file = out.text.trim() + if (!file) return + return Filesystem.normalizePath(file) +} + +export async function resolvePath(text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { + const file = await cygpath(shell, text) + if (file) return file + } + return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) + } + return path.resolve(root, text) +} + +export function formatShellDescription(template: string, opts: { name: string; shellName: string; chaining: string }) { + return template + .replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", opts.name) + .replaceAll("${shellName}", opts.shellName) + .replaceAll("${chaining}", opts.chaining) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) +} + +export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { + if (scan.dirs.size > 0) { + const globs = Array.from(scan.dirs).map((dir) => { + if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + await ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) + } + + if (scan.patterns.size === 0) return + await ctx.ask({ + permission: permissionName, + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) +} diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 634e41e72430..5e2af7afc1ca 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -1,33 +1,39 @@ import { test, expect } from "bun:test" -import { BashArity } from "../../src/permission/arity" +import { ShellArity } from "../../src/tool/shell/arity" test("arity 1 - unknown commands default to first token", () => { - expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"]) - expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"]) + expect(ShellArity.prefix(["unknown", "command", "subcommand"], "bash")).toEqual(["unknown"]) + expect(ShellArity.prefix(["touch", "foo.txt"], "bash")).toEqual(["touch"]) }) test("arity 2 - two token commands", () => { - expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["docker", "run", "nginx"], "bash")).toEqual(["docker", "run"]) }) test("arity 3 - three token commands", () => { - expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"]) - expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["aws", "s3", "ls", "my-bucket"], "bash")).toEqual(["aws", "s3", "ls"]) + expect(ShellArity.prefix(["npm", "run", "dev", "script"], "bash")).toEqual(["npm", "run", "dev"]) }) test("longest match wins - nested prefixes", () => { - expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"]) - expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"]) + expect(ShellArity.prefix(["docker", "compose", "up", "service"], "bash")).toEqual(["docker", "compose", "up"]) + expect(ShellArity.prefix(["consul", "kv", "get", "config"], "bash")).toEqual(["consul", "kv", "get"]) }) test("exact length matches", () => { - expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["git", "checkout"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["npm", "run", "dev"], "bash")).toEqual(["npm", "run", "dev"]) }) test("edge cases", () => { - expect(BashArity.prefix([])).toEqual([]) - expect(BashArity.prefix(["single"])).toEqual(["single"]) - expect(BashArity.prefix(["git"])).toEqual(["git"]) + expect(ShellArity.prefix([], "bash")).toEqual([]) + expect(ShellArity.prefix(["single"], "bash")).toEqual(["single"]) + expect(ShellArity.prefix(["git"], "bash")).toEqual(["git"]) +}) + +test("powershell verb-noun structures", () => { + expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"]) + expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"]) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b64f..7e48fe33ffd2 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -34,7 +34,11 @@ async function waitForPending(count: number) { test("fromConfig - string value becomes wildcard rule", () => { const result = Permission.fromConfig({ bash: "allow" }) - expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "*", action: "allow" }, + ]) }) test("fromConfig - object value converts to rules array", () => { @@ -42,6 +46,10 @@ test("fromConfig - object value converts to rules array", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, ]) }) @@ -54,6 +62,10 @@ test("fromConfig - mixed string and object values", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, { permission: "edit", pattern: "*", action: "allow" }, { permission: "webfetch", pattern: "*", action: "ask" }, ]) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 92% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 0ea8ea073a51..82dc5ca5150f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { BashTool } from "../../src/tool/shell/bash" +import { PwshTool } from "../../src/tool/shell/pwsh" +import { PowershellTool } from "../../src/tool/shell/powershell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -86,6 +88,20 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise { + const name = sh() + if (name === "pwsh") return "pwsh" + if (name === "powershell") return "powershell" + return "bash" +} + +const getTool = async () => { + const name = sh() + if (name === "pwsh") return await PwshTool.init() + if (name === "powershell") return await PowershellTool.init() + return await BashTool.init() +} + const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { for (const item of shells) { test( @@ -113,12 +129,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo test", @@ -133,13 +149,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -149,7 +165,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo hello") }, }) @@ -160,7 +176,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -170,7 +186,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo foo") expect(requests[0].patterns).toContain("echo bar") }, @@ -184,7 +200,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -193,7 +209,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host bar") @@ -208,7 +224,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -242,7 +258,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await bash.execute( @@ -253,7 +269,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) expect(bashReq).toBeDefined() @@ -273,7 +289,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -301,7 +317,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await bash.execute( @@ -312,7 +328,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) expect(bashReq).toBeDefined() @@ -331,7 +347,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -359,7 +375,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -388,7 +404,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -416,7 +432,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -448,7 +464,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -481,7 +497,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -508,7 +524,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -538,7 +554,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -568,7 +584,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -578,7 +594,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), @@ -597,7 +613,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -606,7 +622,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.always).not.toContain("a *") @@ -622,7 +638,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -645,7 +661,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -673,7 +689,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) for (const dir of forms(outerTmp.path)) { @@ -707,7 +723,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -737,7 +753,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -772,7 +788,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const filepath = path.join(outerTmp.path, "outside.txt") @@ -803,7 +819,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -823,7 +839,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -844,7 +860,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -853,7 +869,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeUndefined() }, }) @@ -864,7 +880,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -873,7 +889,7 @@ describe("tool.bash permissions", () => { capture(requests, err), ), ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") }, @@ -885,10 +901,10 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") }, @@ -896,12 +912,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash truncation", () => { - test("truncates output exceeding line limit", async () => { +describe("tool.shell truncation", () => { + each("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { @@ -917,11 +933,11 @@ describe("tool.bash truncation", () => { }) }) - test("truncates output exceeding byte limit", async () => { + each("truncates output exceeding byte limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { @@ -937,11 +953,11 @@ describe("tool.bash truncation", () => { }) }) - test("does not truncate small output", async () => { + each("does not truncate small output", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo hello", @@ -955,11 +971,11 @@ describe("tool.bash truncation", () => { }) }) - test("full output is saved to file when truncated", async () => { + each("full output is saved to file when truncated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { From 67dfbcbcfd0e40564298450d7cc9d30be10c38b7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:12:36 +1000 Subject: [PATCH 13/84] fix: use dynamic imports for tree-sitter and shell-aware metadata tags --- packages/opencode/src/tool/shell/parser.ts | 3 ++- packages/opencode/src/tool/shell/runner.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index c1507553eb94..6260ac6042c8 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,4 +1,4 @@ -import { Language, Parser, type Node } from "web-tree-sitter" +import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" import { Instance } from "@/project/instance" @@ -141,6 +141,7 @@ export namespace ShellParser { const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { with: { type: "wasm" }, }) + const { Language } = await import("web-tree-sitter") const bashPath = resolveWasm(bashWasm) const psPath = resolveWasm(psWasm) const bashLanguage = await Language.load(bashPath) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 057604076409..9bdfbb66f9a9 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -121,10 +121,10 @@ export namespace ShellRunner { }) const metadata: string[] = [] - if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") if (metadata.length > 0) { - output += "\n\n\n" + metadata.join("\n") + "\n" + output += "\n\n\n" + metadata.join("\n") + "\n" } return { From 3e26c3ae8328784197a6d77e90fcf376c99de73b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:15:58 +1000 Subject: [PATCH 14/84] refactor: extract shell tool factory to eliminate duplication --- packages/opencode/src/tool/shell/bash.ts | 81 ++----------------- .../opencode/src/tool/shell/powershell.ts | 81 ++----------------- packages/opencode/src/tool/shell/pwsh.ts | 81 ++----------------- packages/opencode/src/tool/shell/util.ts | 70 ++++++++++++++++ 4 files changed, 88 insertions(+), 225 deletions(-) diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 98e05c82d6b3..3539f18482e6 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "bash-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "bash" - -export const BashTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("bash tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "Bash", - chaining: - "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const BashTool = createShellTool( + "bash", + "Bash", + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", +) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index cc63ac2c029f..0910ec134744 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "powershell-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "powershell" - -export const PowershellTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("powershell tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "Windows PowerShell", - chaining: - "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const PowershellTool = createShellTool( + "powershell", + "Windows PowerShell", + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", +) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index 525c9d4be34a..d22dc2bd70f1 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "pwsh-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "pwsh" - -export const PwshTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("pwsh tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "PowerShell Core", - chaining: - "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const PwshTool = createShellTool( + "pwsh", + "PowerShell Core", + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", +) diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index 0091ff8e8ce3..335fe3432fa6 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -91,6 +91,76 @@ export function formatShellDescription(template: string, opts: { name: string; s .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) } +import z from "zod" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Flag } from "@/flag/flag" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export type ShellType = "bash" | "pwsh" | "powershell" + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +export function createShellTool(id: ShellType, shellName: string, chaining: string) { + const log = Log.create({ service: `${id}-tool` }) + + return Tool.define(id, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info(`${id} tool using shell`, { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { name, shellName, chaining }), + parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), + async execute(params, ctx) { + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + + const scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: id, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, id) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } + }) +} + export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { From 51ebba2975c824ea7035b6afc4294099a5d2afab Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:18:50 +1000 Subject: [PATCH 15/84] refactor: add shell-specific guidance to each tool prompt --- packages/opencode/src/tool/shell/bash.ts | 16 ++++++++---- .../opencode/src/tool/shell/powershell.ts | 20 +++++++++++---- packages/opencode/src/tool/shell/pwsh.ts | 18 +++++++++---- packages/opencode/src/tool/shell/shell.txt | 2 ++ packages/opencode/src/tool/shell/util.ts | 25 +++++++++++++------ 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 3539f18482e6..95fc524a0118 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -1,7 +1,13 @@ import { createShellTool } from "./util" -export const BashTool = createShellTool( - "bash", - "Bash", - "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", -) +export const BashTool = createShellTool({ + id: "bash", + shellName: "Bash", + chaining: + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# Bash shell notes +- This is a POSIX-compatible shell. Standard Unix conventions apply. +- Use double quotes for variable interpolation, single quotes for literal strings. +- Use \`$(...)\` for command substitution (not backticks). +- Redirect stderr with \`2>&1\` or \`2>/dev/null\`.`, +}) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index 0910ec134744..0df02aee49e9 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -1,7 +1,17 @@ import { createShellTool } from "./util" -export const PowershellTool = createShellTool( - "powershell", - "Windows PowerShell", - "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", -) +export const PowershellTool = createShellTool({ + id: "powershell", + shellName: "Windows PowerShell 5.1", + chaining: + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + guidance: `# Windows PowerShell 5.1 shell notes +- This is Windows PowerShell 5.1 (legacy), NOT PowerShell 7+. It does NOT support \`&&\` or \`||\` pipeline chain operators. +- For conditional chaining use: \`cmd1; if ($?) { cmd2 }\` +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` resolve to cmdlets with different behavior than Unix equivalents. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`) not backslash. +- Some modern PowerShell features (ternary operator, null-coalescing, etc.) are NOT available in 5.1.`, +}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index d22dc2bd70f1..cff2cd702a1c 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -1,7 +1,15 @@ import { createShellTool } from "./util" -export const PwshTool = createShellTool( - "pwsh", - "PowerShell Core", - "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", -) +export const PwshTool = createShellTool({ + id: "pwsh", + shellName: "PowerShell 7+", + chaining: + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# PowerShell 7+ (pwsh) shell notes +- This is PowerShell 7+ (Core), a cross-platform shell. It supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` are available but resolve to cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`) not backslash.`, +}) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 7ef2ca73fb08..f6454cab8f27 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -6,6 +6,8 @@ All commands run in ${directory} by default. Use the \`workdir\` parameter if yo IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. +${guidance} + Before executing the command, please follow these steps: 1. Directory Verification: diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index 335fe3432fa6..dbd78a192e6f 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -80,13 +80,17 @@ export async function resolvePath(text: string, root: string, shell: string) { return path.resolve(root, text) } -export function formatShellDescription(template: string, opts: { name: string; shellName: string; chaining: string }) { +export function formatShellDescription( + template: string, + opts: { name: string; shellName: string; chaining: string; guidance: string }, +) { return template .replaceAll("${directory}", Instance.directory) .replaceAll("${os}", process.platform) .replaceAll("${shell}", opts.name) .replaceAll("${shellName}", opts.shellName) .replaceAll("${chaining}", opts.chaining) + .replaceAll("${guidance}", opts.guidance) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) } @@ -102,16 +106,21 @@ export type ShellType = "bash" | "pwsh" | "powershell" const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -export function createShellTool(id: ShellType, shellName: string, chaining: string) { - const log = Log.create({ service: `${id}-tool` }) +export function createShellTool(opts: { id: ShellType; shellName: string; chaining: string; guidance: string }) { + const log = Log.create({ service: `${opts.id}-tool` }) - return Tool.define(id, async () => { + return Tool.define(opts.id, async () => { const shell = Shell.acceptable() const name = Shell.name(shell) - log.info(`${id} tool using shell`, { shell, name }) + log.info(`${opts.id} tool using shell`, { shell, name }) return { - description: formatShellDescription(DESCRIPTION, { name, shellName, chaining }), + description: formatShellDescription(DESCRIPTION, { + name, + shellName: opts.shellName, + chaining: opts.chaining, + guidance: opts.guidance, + }), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -138,11 +147,11 @@ export function createShellTool(id: ShellType, shellName: string, chaining: stri command: params.command, cwd, shell, - shellType: id, + shellType: opts.id, }) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await askPermission(ctx, scan, id) + await askPermission(ctx, scan, opts.id) return ShellRunner.run( { From 48f9082d0a938fcd9b458999ec5c472a3125555b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:24:49 +1000 Subject: [PATCH 16/84] refactor: use positive tone in shell guidance prompts --- packages/opencode/src/tool/shell/shell.txt | 31 ++++++++++------------ 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index f6454cab8f27..2faabb2c3d1f 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -2,7 +2,7 @@ Executes a given shell command in a persistent ${shellName} session with optiona Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the \`workdir\` parameter if you need to run a command in a different directory. AVOID using \`cd && \` patterns - use \`workdir\` instead. +All commands run in ${directory} by default. Use the \`workdir\` parameter to run a command in a different directory instead of \`cd && \` patterns. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -28,27 +28,24 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - + - Use the `workdir` parameter to change directories instead of chaining commands with `cd`. + Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - + # Committing changes with git @@ -59,7 +56,7 @@ Git Safety Protocol: - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it -- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: +- Only use git commit --amend when ALL conditions are met: (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") From 676519d79d6062d2c489602ddd3dd34816901be1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:42:42 +1000 Subject: [PATCH 17/84] refactor: apply positive guidance and parameterize shell commands in prompt template --- packages/opencode/src/tool/shell/bash.ts | 11 ++--- .../opencode/src/tool/shell/powershell.ts | 15 +++--- packages/opencode/src/tool/shell/pwsh.ts | 11 +++-- packages/opencode/src/tool/shell/shell.txt | 49 ++++++++++--------- packages/opencode/src/tool/shell/util.ts | 23 ++++++++- 5 files changed, 67 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 95fc524a0118..3a07511b21f7 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -2,12 +2,11 @@ import { createShellTool } from "./util" export const BashTool = createShellTool({ id: "bash", - shellName: "Bash", + shellName: "bash", + toolName: "Bash", + listCmd: "ls", + gitCmds: "git bash commands", chaining: "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: `# Bash shell notes -- This is a POSIX-compatible shell. Standard Unix conventions apply. -- Use double quotes for variable interpolation, single quotes for literal strings. -- Use \`$(...)\` for command substitution (not backticks). -- Redirect stderr with \`2>&1\` or \`2>/dev/null\`.`, + guidance: "", }) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index 0df02aee49e9..fe12aadb1402 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -2,16 +2,17 @@ import { createShellTool } from "./util" export const PowershellTool = createShellTool({ id: "powershell", - shellName: "Windows PowerShell 5.1", + shellName: "Windows PowerShell", + toolName: "PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", chaining: - "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + "use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", guidance: `# Windows PowerShell 5.1 shell notes -- This is Windows PowerShell 5.1 (legacy), NOT PowerShell 7+. It does NOT support \`&&\` or \`||\` pipeline chain operators. -- For conditional chaining use: \`cmd1; if ($?) { cmd2 }\` +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. - Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` resolve to cmdlets with different behavior than Unix equivalents. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. - Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. - To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`) not backslash. -- Some modern PowerShell features (ternary operator, null-coalescing, etc.) are NOT available in 5.1.`, +- Escape special characters with backtick (\\\`).`, }) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index cff2cd702a1c..59e9b626adf3 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -2,14 +2,17 @@ import { createShellTool } from "./util" export const PwshTool = createShellTool({ id: "pwsh", - shellName: "PowerShell 7+", + shellName: "PowerShell Core", + toolName: "PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", chaining: "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", guidance: `# PowerShell 7+ (pwsh) shell notes -- This is PowerShell 7+ (Core), a cross-platform shell. It supports pipeline chain operators (\`&&\` and \`||\`). +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). - Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` are available but resolve to cmdlets. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. - Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. - To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`) not backslash.`, +- Escape special characters with backtick (\\\`).`, }) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 2faabb2c3d1f..a874ad619d70 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,8 +1,8 @@ -Executes a given shell command in a persistent ${shellName} session with optional timeout, ensuring proper handling and security measures. +Executes a given ${shellName} command in a persistent shell session with optional timeout, ensuring proper handling and security measures. Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the \`workdir\` parameter to run a command in a different directory instead of \`cd && \` patterns. +All commands run in ${directory} by default. Use the \`workdir\` parameter if you need to run a command in a different directory. AVOID using \`cd && \` patterns - use \`workdir\` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -11,8 +11,8 @@ ${guidance} Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use \`${listCmd}\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`${listCmd} foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") @@ -28,24 +28,27 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. Use Read with offset/limit to read specific sections or Grep to search the full content. - - - Use the dedicated tools for file operations and communication instead of shell commands: - - File search: Use Glob - - Content search: Use Grep - - Read files: Use Read - - Edit files: Use Edit - - Write files: Use Write - - Communication: Output text directly + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using ${toolName} with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < + - AVOID using \`cd && \`. Use the \`workdir\` parameter to change directories instead. + Use workdir="/foo/bar" with command: pytest tests - + + + cd /foo/bar && pytest tests + # Committing changes with git @@ -56,7 +59,7 @@ Git Safety Protocol: - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it -- Only use git commit --amend when ALL conditions are met: +- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") @@ -64,7 +67,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCmds} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -81,18 +84,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCmds} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCmds} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index dbd78a192e6f..46845b731c1f 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -82,7 +82,15 @@ export async function resolvePath(text: string, root: string, shell: string) { export function formatShellDescription( template: string, - opts: { name: string; shellName: string; chaining: string; guidance: string }, + opts: { + name: string + shellName: string + chaining: string + guidance: string + listCmd: string + toolName: string + gitCmds: string + }, ) { return template .replaceAll("${directory}", Instance.directory) @@ -106,7 +114,15 @@ export type ShellType = "bash" | "pwsh" | "powershell" const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -export function createShellTool(opts: { id: ShellType; shellName: string; chaining: string; guidance: string }) { +export function createShellTool(opts: { + id: ShellType + shellName: string + chaining: string + guidance: string + listCmd: string + toolName: string + gitCmds: string +}) { const log = Log.create({ service: `${opts.id}-tool` }) return Tool.define(opts.id, async () => { @@ -120,6 +136,9 @@ export function createShellTool(opts: { id: ShellType; shellName: string; chaini shellName: opts.shellName, chaining: opts.chaining, guidance: opts.guidance, + listCmd: opts.listCmd, + toolName: opts.toolName, + gitCmds: opts.gitCmds, }), parameters: z.object({ command: z.string().describe("The command to execute"), From 95577c75a327fb51851ddba7beba70ef0497a495 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:43:37 +1000 Subject: [PATCH 18/84] fix(config): preserve bash permission compatibility Keep legacy tools.bash migration mapped to the single bash permission since the permission layer already expands it to pwsh and powershell. This preserves the backward-compatible config shape while retaining shell compatibility. --- packages/opencode/src/config/config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0166eec5272d..bfc003f74190 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -588,8 +588,6 @@ export namespace Config { permission.edit = action } else if (tool === "bash") { permission.bash = action - permission.pwsh = action - permission.powershell = action } else { permission[tool] = action } From 6ad6358eb152db4a8a5d1ab27f2ce3da098a1b6f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:01:13 +1000 Subject: [PATCH 19/84] fix: render pwsh and powershell tools correctly in UI This fixes regressions from splitting the shell tools where powershell commands were missing their native exit codes and their correct UI rendering. --- packages/opencode/.opencode/package-lock.json | 31 +++++++++++++++++++ packages/opencode/src/tool/shell/runner.ts | 16 +++++++--- packages/ui/src/components/message-part.tsx | 6 +++- packages/web/src/components/share/part.tsx | 5 +-- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/.opencode/package-lock.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json new file mode 100644 index 000000000000..c36f5973733d --- /dev/null +++ b/packages/opencode/.opencode/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.2.24", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.2.24", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.2.24", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 9bdfbb66f9a9..0c4f7ee05d11 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -11,6 +11,11 @@ export function preview(text: string) { } export namespace ShellRunner { + function wrap(name: string, command: string) { + if (name !== "powershell" && name !== "pwsh") return command + return `${command}; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if ($?) { exit 0 }; exit 1` + } + export async function shellEnv(ctx: Tool.Context, cwd: string) { const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) return { @@ -21,7 +26,7 @@ export namespace ShellRunner { export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrap(name, command)], { cwd, env, stdio: ["ignore", "pipe", "pipe"], @@ -54,6 +59,7 @@ export namespace ShellRunner { ) { const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) let output = "" + let code: number | null = null ctx.metadata({ metadata: { @@ -103,12 +109,14 @@ export namespace ShellRunner { ctx.abort.removeEventListener("abort", abort) } - proc.once("exit", () => { + proc.once("exit", (next) => { exited = true + code = next }) - proc.once("close", () => { + proc.once("close", (next) => { exited = true + code = next cleanup() resolve() }) @@ -131,7 +139,7 @@ export namespace ShellRunner { title: input.description, metadata: { output: preview(output), - exit: proc.exitCode, + exit: code, description: input.description, }, output, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 03477e5a7f2f..a2e644d88778 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -269,6 +269,8 @@ export type ToolInfo = { subtitle?: string } +const SHELL = new Set(["bash", "pwsh", "powershell"]) + function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -331,6 +333,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } case "bash": + case "pwsh": + case "powershell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -518,7 +522,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell + if (SHELL.has(tool)) return shell if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index c7d177df7df2..3558fd9452e3 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,6 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 +const SHELL = new Set(["bash", "pwsh", "powershell"]) export interface PartProps { index: number @@ -90,7 +91,7 @@ export function Part(props: PartProps) { - + @@ -240,7 +241,7 @@ export function Part(props: PartProps) { state={props.part.state} /> - + Date: Fri, 3 Apr 2026 14:27:03 +1000 Subject: [PATCH 20/84] fix(shell): preserve powershell exit codes Use a multiline PowerShell trailer so native Windows commands keep their actual exit status without masking cmdlet failures, and add focused regression coverage. Remove the accidentally committed .opencode package-lock to keep generated state out of the branch. --- packages/opencode/src/tool/shell/runner.ts | 10 +++-- packages/opencode/test/tool/shell.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 0c4f7ee05d11..2adbf77b14b2 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -11,9 +11,11 @@ export function preview(text: string) { } export namespace ShellRunner { - function wrap(name: string, command: string) { - if (name !== "powershell" && name !== "pwsh") return command - return `${command}; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if ($?) { exit 0 }; exit 1` + function preserveExitCode(command: string) { + return `${command} +if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } +if ($?) { exit 0 } +exit 1` } export async function shellEnv(ctx: Tool.Context, cwd: string) { @@ -26,7 +28,7 @@ export namespace ShellRunner { export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrap(name, command)], { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 53dee213115e..39b54a7a26a1 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -115,6 +115,15 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom } } +const eachps = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { + for (const item of ps) { + test( + `${name} [${item.label}]`, + withShell(item, () => fn(item)), + ) + } +} + const capture = (requests: Array>, stop?: Error) => ({ ...ctx, ask: async (req: Omit) => { @@ -1018,6 +1027,40 @@ describe("tool.shell runtime", () => { }) }) + eachps("preserves native exit code with trailing comment", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await getTool() + const result = await bash.execute( + { + command: `${js("process.exit(42)")} # keep wrapper separate`, + description: "Trailing comment exit", + }, + ctx, + ) + expect(result.metadata.exit).toBe(42) + }, + }) + }) + + eachps("returns non-zero exit for powershell cmdlet errors", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await getTool() + const result = await bash.execute( + { + command: "Write-Error x", + description: "Cmdlet error exit", + }, + ctx, + ) + expect(result.metadata.exit).toBe(1) + }, + }) + }) + each("streams metadata updates progressively", async () => { await Instance.provide({ directory: projectRoot, From baf476f4317f941d1ef07f4780bf09fccc2a2209 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:29:04 +1000 Subject: [PATCH 21/84] test(shell): handle nullable exit metadata Make the shell exit assertions typecheck cleanly while keeping the PowerShell regression coverage. Remove the accidentally committed .opencode package-lock so generated state does not ship in the branch. --- packages/opencode/.opencode/package-lock.json | 31 ------------------- packages/opencode/test/tool/shell.test.ts | 10 +++--- 2 files changed, 5 insertions(+), 36 deletions(-) delete mode 100644 packages/opencode/.opencode/package-lock.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json deleted file mode 100644 index c36f5973733d..000000000000 --- a/packages/opencode/.opencode/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "*" - } - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.2.24", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.2.24", - "zod": "4.1.8" - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.2.24", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 39b54a7a26a1..224e4d6fc5b6 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -155,7 +155,7 @@ describe("tool.shell", () => { }, ctx, ) - expect(result.metadata.exit).toBe(0) + expect(result.metadata.exit ?? -1).toBe(0) expect(result.metadata.output).toContain("test") }, }) @@ -1005,7 +1005,7 @@ describe("tool.shell runtime", () => { ) expect(result.output).toContain("333") expect(result.output).toContain("444") - expect(result.metadata.exit).toBe(0) + expect(result.metadata.exit ?? -1).toBe(0) }, }) }) @@ -1022,7 +1022,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(42) + expect(result.metadata.exit ?? -1).toBe(42) }, }) }) @@ -1039,7 +1039,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(42) + expect(result.metadata.exit ?? -1).toBe(42) }, }) }) @@ -1056,7 +1056,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(1) + expect(result.metadata.exit ?? -1).toBe(1) }, }) }) From 2eb9ae4d34340e298ade122cc0051a464615561d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:56:40 +1000 Subject: [PATCH 22/84] refactor(shell): centralize shell tool identity Move shell tool ID checks behind shared helpers so runtime code and tests stop duplicating bash, pwsh, and powershell branches. This keeps shell-specific behavior aligned across consumers and makes follow-on shell changes less error-prone. --- .../app/e2e/prompt/prompt-history.spec.ts | 12 +----- packages/app/e2e/prompt/prompt-shell.spec.ts | 11 +---- packages/app/e2e/utils.ts | 10 ++++- packages/opencode/src/acp/agent.ts | 41 +++++++++---------- packages/opencode/src/agent/agent.ts | 2 - packages/opencode/src/cli/cmd/agent.ts | 5 +-- packages/opencode/src/cli/cmd/run.ts | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/registry.ts | 8 ++-- packages/opencode/src/tool/shell/arity.ts | 10 ++--- packages/opencode/src/tool/shell/id.ts | 19 +++++++++ packages/opencode/src/tool/shell/parser.ts | 5 ++- packages/opencode/src/tool/shell/runner.ts | 3 +- packages/opencode/src/tool/shell/util.ts | 3 +- .../test/session/snapshot-tool-race.test.ts | 13 +++--- packages/opencode/test/tool/shell.test.ts | 20 ++++----- packages/ui/src/components/message-part.tsx | 16 ++++---- 19 files changed, 99 insertions(+), 92 deletions(-) create mode 100644 packages/opencode/src/tool/shell/id.ts diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index 55cb0c9aa33d..0ecac65ef47f 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -1,20 +1,12 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { assistantText } from "../actions" import { promptSelector } from "../selectors" -import { createSdk } from "../utils" +import { createSdk, isShell } from "../utils" const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() type Sdk = ReturnType -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} - async function wait(page: Page, value: string) { await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) } @@ -31,7 +23,7 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { const part = messages .filter((item) => item.info.role === "assistant") .flatMap((item) => item.parts) - .filter(isBash) + .filter(isShell) .find((item) => item.state.input?.command === cmd && item.state.status === "completed") if (!part || part.state.status !== "completed") return diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index d81f1d4c40f1..366011bdef00 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,13 +1,6 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" import { withSession } from "../actions" - -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} +import { isShell } from "../utils" async function setAutoAccept(page: Parameters[0]["page"], enabled: boolean) { const button = page.locator('[data-action="prompt-permissions"]').first() @@ -42,7 +35,7 @@ test("shell mode runs a command in the project directory", async ({ page, projec if (!msg) return const part = msg.parts - .filter(isBash) + .filter(isShell) .find((item) => item.state.input?.command === cmd && item.state.status === "completed") if (!part || part.state.status !== "completed") return diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 17a878566466..9e4d13e0ad72 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,4 +1,4 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { createOpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2/client" import { base64Encode, checksum } from "@opencode-ai/util/encode" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" @@ -18,6 +18,7 @@ const serverLabels = (() => { export const serverNames = [...new Set(serverLabels)] export const serverUrls = serverNames.map((name) => `http://${name}`) +const shell = new Set(["bash", "pwsh", "powershell"]) const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -30,6 +31,13 @@ export function createSdk(directory?: string, baseUrl = serverUrl) { return createOpencodeClient({ baseUrl, directory, throwOnError: true }) } +export function isShell(part: unknown): part is ToolPart { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || typeof part.tool !== "string" || !shell.has(part.tool)) return false + return "state" in part +} + export async function resolveDirectory(directory: string, baseUrl = serverUrl) { return createSdk(directory, baseUrl) .path.get() diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f5b264cbe5d1..a2c93cbf5a54 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -38,6 +38,7 @@ import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" +import { ShellTool } from "@/tool/shell/id" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { Todo } from "@/session/todo" @@ -138,7 +139,7 @@ export namespace ACP { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -277,16 +278,16 @@ export namespace ACP { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (ShellTool.has(part.tool)) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -305,7 +306,7 @@ export namespace ACP { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -336,7 +337,7 @@ export namespace ACP { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -417,7 +418,7 @@ export namespace ACP { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -832,10 +833,10 @@ export namespace ACP { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -866,7 +867,7 @@ export namespace ACP { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -946,7 +947,7 @@ export namespace ACP { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1100,8 +1101,8 @@ export namespace ACP { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash" && part.tool !== "pwsh" && part.tool !== "powershell") return + private shellOutput(part: ToolPart) { + if (!ShellTool.has(part.tool)) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1502,11 +1503,9 @@ export namespace ACP { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + if (ShellTool.has(tool)) return "execute" + switch (tool) { - case "bash": - case "pwsh": - case "powershell": - return "execute" case "webfetch": return "fetch" @@ -1532,6 +1531,8 @@ export namespace ACP { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + if (ShellTool.has(tool)) return [] + switch (tool) { case "read": case "edit": @@ -1540,10 +1541,6 @@ export namespace ACP { case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": - case "pwsh": - case "powershell": - return [] case "list": return input["path"] ? [{ path: input["path"] }] : [] default: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 02592b0d6a9d..0c6fe6ec91c8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -168,8 +168,6 @@ export namespace Agent { glob: "allow", list: "allow", bash: "allow", - pwsh: "allow", - powershell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a8e73c90b756..d0777d3ef774 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,15 +9,14 @@ import fs from "fs/promises" import { Filesystem } from "../../util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { ShellTool } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" const AVAILABLE_TOOLS = [ - "bash", - "pwsh", - "powershell", + ...ShellTool.ids, "read", "write", "edit", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 615cbf6494f9..de09719933a8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -25,6 +25,7 @@ import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/shell/bash" +import { ShellTool } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -411,8 +412,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") - return bash(props(part)) + if (ShellTool.has(part.tool)) return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) 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 a9129bb9f446..e47b50ac3315 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -36,6 +36,7 @@ import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/shell/bash" +import { ShellTool } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1519,7 +1520,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9147da78aad5..a5723e1d7a77 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@/global" +import { ShellTool } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -283,7 +284,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash" || permission === "pwsh" || permission === "powershell") { + if (ShellTool.has(permission)) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 115c93547bff..823bb971b849 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,6 +43,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellTool } from "@/tool/shell/id" import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -791,7 +792,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(msg) const sh = Shell.preferred() const name = Shell.name(sh) - const tool = name === "pwsh" ? "pwsh" : name === "powershell" ? "powershell" : "bash" + const tool = ShellTool.from(name) const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6dce6d75e16a..15f84a781a93 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -32,6 +32,7 @@ import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { BashTool } from "./shell/bash" +import { ShellTool } from "./shell/id" import { PwshTool } from "./shell/pwsh" import { PowershellTool } from "./shell/powershell" import { Shell } from "@/shell/shell" @@ -39,6 +40,7 @@ import { Env } from "../env" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) + const shells = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const type State = { custom: Tool.Info[] @@ -118,14 +120,12 @@ export namespace ToolRegistry { const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) { const cfg = yield* config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - - const shellName = Shell.name(Shell.acceptable()) - const ActiveShellTool = shellName === "pwsh" ? PwshTool : shellName === "powershell" ? PowershellTool : BashTool + const active = shells[ShellTool.from(Shell.name(Shell.acceptable()))] return [ InvalidTool, ...(question ? [QuestionTool] : []), - ActiveShellTool, + active, ReadTool, GlobTool, GrepTool, diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index 3ec40b664619..c97d4b8eec39 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,10 +1,8 @@ +import { ShellTool } from "./id" + export namespace ShellArity { - export function prefix(tokens: string[], shellType: "bash" | "pwsh" | "powershell") { - if ( - (shellType === "pwsh" || shellType === "powershell") && - tokens.length > 0 && - /^[a-z]+-[a-z]+$/i.test(tokens[0]) - ) { + export function prefix(tokens: string[], shellType: ShellTool.ID) { + if (ShellTool.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { return [tokens[0]] } for (let len = tokens.length; len > 0; len--) { diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 000000000000..0ea57064cf0a --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +export namespace ShellTool { + export const ids = ["bash", "pwsh", "powershell"] as const + export type ID = (typeof ids)[number] + + const shell = new Set(ids) + const ps = new Set(["pwsh", "powershell"]) + + export function has(value: string): value is ID { + return shell.has(value) + } + + export function from(value: string): ID { + return has(value) ? value : "bash" + } + + export function powershell(value: string) { + return ps.has(value) + } +} diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 6260ac6042c8..3860f65ea5f1 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,6 +1,7 @@ import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" +import { ShellTool } from "./id" import { Instance } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import path from "path" @@ -160,9 +161,9 @@ export namespace ShellParser { command: string cwd: string shell: string - shellType: "bash" | "pwsh" | "powershell" + shellType: ShellTool.ID }): Promise { - const isPwsh = opts.shellType === "pwsh" || opts.shellType === "powershell" + const isPwsh = ShellTool.powershell(opts.shellType) const parsers = await getParser() const parser = isPwsh ? parsers.ps : parsers.bash diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 2adbf77b14b2..b86d39cecb9b 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process" import { Shell } from "@/shell/shell" import { Tool } from "../tool" import { Plugin } from "@/plugin" +import { ShellTool } from "./id" const MAX_METADATA_LENGTH = 30_000 @@ -27,7 +28,7 @@ exit 1` } export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { + if (process.platform === "win32" && ShellTool.powershell(name)) { return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index b1fb167fbdc6..a09289235cc4 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -108,12 +108,13 @@ export function formatShellDescription( import z from "zod" import DESCRIPTION from "./shell.txt" +import { ShellTool } from "./id" import { Log } from "@/util/log" import { Flag } from "@/flag/flag" import { ShellParser } from "./parser" import { ShellRunner } from "./runner" -export type ShellType = "bash" | "pwsh" | "powershell" +export type ShellType = ShellTool.ID const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 4f4376e341ca..cf9f340e9886 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -16,10 +16,12 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" +import { Shell } from "../../src/shell/shell" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" +import { ShellTool } from "../../src/tool/shell/id" import { Log } from "../../src/util/log" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -42,7 +44,6 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionStatus } from "../../src/session/status" -import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" @@ -183,13 +184,15 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - // Use bash tool (always registered) to create a file + const shell = ShellTool.from(Shell.name(Shell.acceptable())) + + // Use the active shell tool to create a file const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` - yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), shell, { command, description: "create test file", }) - yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes(shell), "done") // Seed user message yield* prompt.prompt({ @@ -217,7 +220,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) const tool = allMsgs .flatMap((m) => m.parts) - .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash") + .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === shell) expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 224e4d6fc5b6..26a6e7d05667 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -3,6 +3,7 @@ import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/shell/bash" +import { ShellTool } from "../../src/tool/shell/id" import { PwshTool } from "../../src/tool/shell/pwsh" import { PowershellTool } from "../../src/tool/shell/powershell" import { Instance } from "../../src/project/instance" @@ -47,15 +48,14 @@ const shells = (() => { (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, ) })() -const PS = new Set(["pwsh", "powershell"]) -const ps = shells.filter((item) => PS.has(item.label)) +const ps = shells.filter((item) => ShellTool.powershell(item.label)) const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) const js = (code: string, ...args: Array) => { const tail = args.length ? ` ${args.map(String).join(" ")}` : "" const text = `${bin} -e ${evalarg(code)}${tail}` - if (PS.has(sh())) return `& ${text}` + if (ShellTool.powershell(sh())) return `& ${text}` return text } @@ -92,18 +92,12 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise { - const name = sh() - if (name === "pwsh") return "pwsh" - if (name === "powershell") return "powershell" - return "bash" -} +const expectedPermission = () => ShellTool.from(sh()) + +const tools = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const const getTool = async () => { - const name = sh() - if (name === "pwsh") return await PwshTool.init() - if (name === "powershell") return await PowershellTool.init() - return await BashTool.init() + return await tools[ShellTool.from(sh())].init() } const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a2e644d88778..a1b1b00c9ccf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -278,6 +278,14 @@ function agentTitle(i18n: UiI18n, type?: string) { export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() + if (SHELL.has(tool)) { + return { + icon: "console", + title: i18n.t("ui.tool.shell"), + subtitle: input.description, + } + } + switch (tool) { case "read": return { @@ -332,14 +340,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { subtitle: input.description, } } - case "bash": - case "pwsh": - case "powershell": - return { - icon: "console", - title: i18n.t("ui.tool.shell"), - subtitle: input.description, - } case "edit": return { icon: "code-lines", From 32ec3666b7ec18d644a6a8f720a75056bf27daa1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:30 +1000 Subject: [PATCH 23/84] fix(shell): keep shell config consistent Treat shell access as one logical toggle during agent creation and apply bash compatibility rules before explicit per-shell overrides. This avoids disabling the active Windows shell unexpectedly and keeps pwsh and powershell overrides deterministic. --- packages/opencode/src/cli/cmd/agent.ts | 14 +---- packages/opencode/src/permission/index.ts | 58 +++++++------------ .../opencode/test/permission/next.test.ts | 24 ++++++++ 3 files changed, 46 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index d0777d3ef774..70082c8e2e75 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,24 +9,12 @@ import fs from "fs/promises" import { Filesystem } from "../../util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" -import { ShellTool } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = [ - ...ShellTool.ids, - "read", - "write", - "edit", - "list", - "glob", - "grep", - "webfetch", - "task", - "todowrite", -] +const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 638d5ebe12a5..1ddd9cec324b 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -276,46 +276,30 @@ export namespace Permission { return pattern } + function pushRules(ruleset: Ruleset, permission: string, value: Config.PermissionRule) { + if (typeof value === "string") { + ruleset.push({ permission, action: value, pattern: "*" }) + return + } + + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission, pattern: expand(pattern), action })), + ) + } + export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] + + const bash = permission["bash"] + if (bash !== undefined) { + pushRules(ruleset, "bash", bash) + pushRules(ruleset, "pwsh", bash) + pushRules(ruleset, "powershell", bash) + } + for (const [key, value] of Object.entries(permission)) { - if (key === "bash") { - if (typeof value === "string") { - ruleset.push({ permission: "bash", action: value, pattern: "*" }) - ruleset.push({ permission: "pwsh", action: value, pattern: "*" }) - ruleset.push({ permission: "powershell", action: value, pattern: "*" }) - } else { - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "bash", - pattern: expand(pattern), - action, - })), - ) - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "pwsh", - pattern: expand(pattern), - action, - })), - ) - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "powershell", - pattern: expand(pattern), - action, - })), - ) - } - continue - } - if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) - continue - } - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), - ) + if (key === "bash") continue + pushRules(ruleset, key, value) } return ruleset } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7e48fe33ffd2..b98f4a3ae0b3 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -71,6 +71,30 @@ test("fromConfig - mixed string and object values", () => { ]) }) +test("fromConfig - explicit pwsh overrides bash regardless of key order", () => { + const result = Permission.fromConfig({ + pwsh: "deny", + bash: "allow", + }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "deny" }, + ]) + expect(Permission.evaluate("pwsh", "ls", result).action).toBe("deny") + expect(Permission.evaluate("bash", "ls", result).action).toBe("allow") +}) + +test("fromConfig - explicit powershell pattern overrides bash pattern regardless of key order", () => { + const result = Permission.fromConfig({ + powershell: { "rm *": "deny" }, + bash: { "*": "allow", "rm *": "ask" }, + }) + expect(Permission.evaluate("powershell", "rm foo", result).action).toBe("deny") + expect(Permission.evaluate("pwsh", "rm foo", result).action).toBe("ask") +}) + test("fromConfig - empty object", () => { const result = Permission.fromConfig({}) expect(result).toEqual([]) From 25551172c9c546a32c79703d62d04f97653329d2 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:04:41 +1000 Subject: [PATCH 24/84] fix(shell): avoid abort hangs and utf8 corruption Attach shell process listeners before handling already-aborted tool signals so canceled runs always settle, and decode shell output as UTF-8 to preserve multibyte characters across chunk boundaries. Also lazy-load shell-specific parsers and hoist command sets so parsing work stays focused on the active shell. --- packages/opencode/src/tool/shell/parser.ts | 43 ++++++++++---------- packages/opencode/src/tool/shell/runner.ts | 31 ++++++++------- packages/opencode/test/tool/shell.test.ts | 46 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 3860f65ea5f1..7b8dff37b74b 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -22,6 +22,8 @@ const FILES_PWSH = new Set([ "new-item", "rename-item", ]) +const FILES_BASH = new Set([...CWD, ...FILES_BASE]) +const FILES_PWSH_ALL = new Set([...FILES_BASH, ...FILES_PWSH]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) @@ -125,36 +127,38 @@ function pathArgs(list: Part[], isPwsh: boolean) { } export namespace ShellParser { - const getParser = lazy(async () => { - const { Parser } = await import("web-tree-sitter") + const getCore = lazy(async () => { + const tree = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" }, }) const treePath = resolveWasm(treeWasm) - await Parser.init({ + await tree.Parser.init({ locateFile() { return treePath }, }) + return tree + }) + + const getBashParser = lazy(async () => { + const tree = await getCore() const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { with: { type: "wasm" }, }) + const bash = new tree.Parser() + bash.setLanguage(await tree.Language.load(resolveWasm(bashWasm))) + return bash + }) + + const getPsParser = lazy(async () => { + const tree = await getCore() const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { with: { type: "wasm" }, }) - const { Language } = await import("web-tree-sitter") - const bashPath = resolveWasm(bashWasm) - const psPath = resolveWasm(psWasm) - const bashLanguage = await Language.load(bashPath) - const psLanguage = await Language.load(psPath) - - const bash = new Parser() - bash.setLanguage(bashLanguage) - - const ps = new Parser() - ps.setLanguage(psLanguage) - - return { bash, ps } + const ps = new tree.Parser() + ps.setLanguage(await tree.Language.load(resolveWasm(psWasm))) + return ps }) export async function collect(opts: { @@ -164,8 +168,7 @@ export namespace ShellParser { shellType: ShellTool.ID }): Promise { const isPwsh = ShellTool.powershell(opts.shellType) - const parsers = await getParser() - const parser = isPwsh ? parsers.ps : parsers.bash + const parser = isPwsh ? await getPsParser() : await getBashParser() const tree = parser.parse(opts.command) if (!tree) throw new Error("Failed to parse command") @@ -177,14 +180,14 @@ export namespace ShellParser { always: new Set(), } - const filesSet = new Set([...CWD, ...FILES_BASE, ...(isPwsh ? FILES_PWSH : [])]) + const files = isPwsh ? FILES_PWSH_ALL : FILES_BASH for (const node of commands(root)) { const commandParts = parts(node) const tokens = commandParts.map((item) => item.text) const cmd = isPwsh ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && filesSet.has(cmd)) { + if (cmd && files.has(cmd)) { for (const arg of pathArgs(commandParts, isPwsh)) { const resolved = await argPath(arg, opts.cwd, opts.shell, isPwsh) log.info("resolved path", { arg, resolved }) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index b86d39cecb9b..647605d599cc 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -71,8 +71,11 @@ exit 1` }, }) - const append = (chunk: Buffer) => { - output += chunk.toString() + proc.stdout?.setEncoding("utf8") + proc.stderr?.setEncoding("utf8") + + const append = (chunk: string) => { + output += chunk ctx.metadata({ metadata: { output: preview(output), @@ -87,26 +90,16 @@ exit 1` let expired = false let aborted = false let exited = false + let timer: ReturnType const kill = () => Shell.killTree(proc, { exited: () => exited }) - if (ctx.abort.aborted) { - aborted = true - await kill() - } - const abort = () => { aborted = true void kill() } - ctx.abort.addEventListener("abort", abort, { once: true }) - const timer = setTimeout(() => { - expired = true - void kill() - }, input.timeout + 100) - - await new Promise((resolve, reject) => { + const wait = new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timer) ctx.abort.removeEventListener("abort", abort) @@ -131,6 +124,16 @@ exit 1` }) }) + ctx.abort.addEventListener("abort", abort, { once: true }) + timer = setTimeout(() => { + expired = true + void kill() + }, input.timeout + 100) + + if (ctx.abort.aborted) abort() + + await wait + const metadata: string[] = [] if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 26a6e7d05667..aedfacc3a210 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -6,6 +6,7 @@ import { BashTool } from "../../src/tool/shell/bash" import { ShellTool } from "../../src/tool/shell/id" import { PwshTool } from "../../src/tool/shell/pwsh" import { PowershellTool } from "../../src/tool/shell/powershell" +import { ShellRunner } from "../../src/tool/shell/runner" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -966,6 +967,32 @@ describe("tool.shell runtime", () => { }) }) + each("does not hang when already aborted", async (item) => { + const controller = new AbortController() + controller.abort() + const result = await Promise.race([ + ShellRunner.run( + { + shell: item.shell, + name: item.label, + command: js("setTimeout(()=>{},30000)"), + cwd: projectRoot, + env: process.env, + timeout: 500, + description: "Already aborted", + }, + { + ...ctx, + abort: controller.signal, + }, + ), + Bun.sleep(1500).then(() => "timeout" as const), + ]) + expect(result).not.toBe("timeout") + if (result === "timeout") return + expect(result.output).toContain("User aborted the command") + }) + each("terminates command on timeout", async () => { await Instance.provide({ directory: projectRoot, @@ -1055,6 +1082,25 @@ describe("tool.shell runtime", () => { }) }) + each("preserves multibyte utf8 output across chunks", async (item) => { + const result = await ShellRunner.run( + { + shell: item.shell, + name: item.label, + command: js( + "process.stdout.write(Buffer.from([0xF0,0x9F]));setTimeout(()=>process.stdout.write(Buffer.from([0x98,0x80])),20);setTimeout(()=>process.exit(0),40)", + ), + cwd: projectRoot, + env: process.env, + timeout: 1000, + description: "Utf8 output", + }, + ctx, + ) + expect(result.output).toContain("😀") + expect(result.output).not.toContain("\ufffd") + }) + each("streams metadata updates progressively", async () => { await Instance.provide({ directory: projectRoot, From f1547de528438c1fc72a2d3780f1f5cd1970c18a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:35:16 +1000 Subject: [PATCH 25/84] ok --- .../e2e/session/session-composer-dock.spec.ts | 8 +- packages/app/e2e/utils.ts | 3 +- .../composer/session-permission-dock.tsx | 3 +- packages/opencode/src/acp/agent.ts | 10 +- packages/opencode/src/agent/agent.ts | 2 +- .../opencode/src/agent/prompt/explore.txt | 4 +- packages/opencode/src/agent/prompt/title.txt | 2 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 3 +- packages/opencode/src/cli/cmd/run.ts | 8 +- .../tui/feature-plugins/home/tips-view.tsx | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 10 +- .../cli/cmd/tui/routes/session/permission.tsx | 4 +- packages/opencode/src/config/config.ts | 16 +++- packages/opencode/src/permission/evaluate.ts | 4 +- packages/opencode/src/permission/index.ts | 19 ++-- packages/opencode/src/session/index.ts | 7 +- packages/opencode/src/session/llm.ts | 17 +++- packages/opencode/src/session/message-v2.ts | 34 ++++--- packages/opencode/src/session/prompt.ts | 5 +- .../opencode/src/session/prompt/anthropic.txt | 2 +- .../opencode/src/session/prompt/default.txt | 4 +- .../opencode/src/session/prompt/gemini.txt | 16 ++-- packages/opencode/src/session/prompt/gpt.txt | 2 +- packages/opencode/src/session/prompt/kimi.txt | 4 +- packages/opencode/src/session/prompt/plan.txt | 2 +- .../opencode/src/session/prompt/trinity.txt | 2 +- packages/opencode/src/tool/registry.ts | 13 +-- packages/opencode/src/tool/shell/arity.ts | 6 +- packages/opencode/src/tool/shell/bash.ts | 12 --- packages/opencode/src/tool/shell/id.ts | 22 ++++- packages/opencode/src/tool/shell/parser.ts | 6 +- .../opencode/src/tool/shell/powershell.ts | 18 ---- packages/opencode/src/tool/shell/pwsh.ts | 18 ---- packages/opencode/src/tool/shell/runner.ts | 12 +-- packages/opencode/src/tool/shell/tool.ts | 3 + packages/opencode/src/tool/shell/util.ts | 92 +++++++++++++------ .../test/acp/event-subscription.test.ts | 18 ++-- .../opencode/test/cli/tui/transcript.test.ts | 14 +-- packages/opencode/test/config/config.test.ts | 14 +-- .../opencode/test/permission/next.test.ts | 47 ++++------ .../opencode/test/provider/transform.test.ts | 8 +- .../opencode/test/session/compaction.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 4 +- .../opencode/test/session/message-v2.test.ts | 30 +++--- .../test/session/processor-effect.test.ts | 2 +- .../test/session/revert-compact.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 5 +- packages/opencode/test/tool/shell.test.ts | 30 +++--- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- packages/ui/src/components/message-part.tsx | 4 +- .../shell-submessage-motion.stories.tsx | 2 +- .../timeline-playground.stories.tsx | 8 +- .../components/tool-error-card.stories.tsx | 8 +- .../ui/src/components/tool-error-card.tsx | 1 + packages/web/src/components/share/part.tsx | 2 +- 57 files changed, 311 insertions(+), 295 deletions(-) delete mode 100644 packages/opencode/src/tool/shell/bash.ts delete mode 100644 packages/opencode/src/tool/shell/powershell.ts delete mode 100644 packages/opencode/src/tool/shell/pwsh.ts create mode 100644 packages/opencode/src/tool/shell/tool.ts diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index ecacea83dcb8..8a92fdef66fe 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -401,7 +401,7 @@ test("blocked permission flow supports allow once", async ({ page, project }) => { id: "per_e2e_once", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-once"], metadata: { description: "Need permission for command" }, }, @@ -434,7 +434,7 @@ test("blocked permission flow supports reject", async ({ page, project }) => { { id: "per_e2e_reject", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-reject"], }, undefined, @@ -466,7 +466,7 @@ test("blocked permission flow supports allow always", async ({ page, project }) { id: "per_e2e_always", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-always"], metadata: { description: "Need permission for command" }, }, @@ -561,7 +561,7 @@ test("child session permission request blocks parent dock and supports allow onc { id: "per_e2e_child", sessionID: child.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-child"], metadata: { description: "Need child permission" }, }, diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 9e4d13e0ad72..3df083ef4594 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -18,7 +18,6 @@ const serverLabels = (() => { export const serverNames = [...new Set(serverLabels)] export const serverUrls = serverNames.map((name) => `http://${name}`) -const shell = new Set(["bash", "pwsh", "powershell"]) const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -34,7 +33,7 @@ export function createSdk(directory?: string, baseUrl = serverUrl) { export function isShell(part: unknown): part is ToolPart { if (!part || typeof part !== "object") return false if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || typeof part.tool !== "string" || !shell.has(part.tool)) return false + if (!("tool" in part) || part.tool !== "shell") return false return "state" in part } diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 06ff4f4aa715..bd1ecdffd3c2 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -14,8 +14,9 @@ export function SessionPermissionDock(props: { const toolDescription = () => { const key = `settings.permissions.tool.${props.request.permission}.description` + const fallback = props.request.permission === "shell" ? "settings.permissions.tool.bash.description" : key const value = language.t(key as Parameters[0]) - if (value === key) return "" + if (value === key) return fallback === key ? "" : language.t(fallback as Parameters[0]) return value } diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 63e6ddf0fcee..12cf4b1991f0 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -41,7 +41,7 @@ import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { Todo } from "@/session/todo" @@ -289,7 +289,7 @@ export namespace ACP { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (ShellTool.has(part.tool)) { + if (ShellToolID.has(part.tool)) { if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1111,7 +1111,7 @@ export namespace ACP { } private shellOutput(part: ToolPart) { - if (!ShellTool.has(part.tool)) return + if (!ShellToolID.has(part.tool)) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1555,7 +1555,7 @@ export namespace ACP { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() - if (ShellTool.has(tool)) return "execute" + if (ShellToolID.has(tool)) return "execute" switch (tool) { case "webfetch": @@ -1583,7 +1583,7 @@ export namespace ACP { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() - if (ShellTool.has(tool)) return [] + if (ShellToolID.has(tool)) return [] switch (tool) { case "read": diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec91c8..14cfa891179f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -167,7 +167,7 @@ export namespace Agent { grep: "allow", glob: "allow", list: "allow", - bash: "allow", + shell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt index 5761077cbd88..e5e19ff2e57d 100644 --- a/packages/opencode/src/agent/prompt/explore.txt +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -9,10 +9,10 @@ Guidelines: - Use Glob for broad file pattern matching - Use Grep for searching file contents with regex - Use Read when you know the specific file path you need to read -- Use Bash for file operations like copying, moving, or listing directory contents +- Use Shell for file operations like copying, moving, or listing directory contents - Adapt your search approach based on the thoroughness level specified by the caller - Return file paths as absolute paths in your final response - For clear communication, avoid using emojis -- Do not create any files, or run bash commands that modify the user's system state in any way +- Do not create any files, or run shell commands that modify the user's system state in any way Complete the user's search request efficiently and report your findings clearly. diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt index 62960b2c4758..729871e5fa16 100644 --- a/packages/opencode/src/agent/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -14,7 +14,7 @@ Your output must be: - you MUST use the same language as the user message you are summarizing - Title must be grammatically correct and read naturally - no word salad -- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool") +- Never include tool names in the title (e.g. "read tool", "shell tool", "edit tool") - Focus on the main topic or question the user needs to retrieve - Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing" - When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e75..eda4175b9f2b 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,7 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11e1c..a5d4ad3ce2fc 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -869,7 +869,8 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + shell: ["Shell", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 618dea5188d5..20b0f0c823f8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,8 +24,8 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/shell/bash" -import { ShellTool } from "../../tool/shell/id" +import { ShellTool } from "../../tool/shell/tool" +import { ShellToolID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -192,7 +192,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -417,7 +417,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (ShellTool.has(part.tool)) return bash(props(part)) + if (ShellToolID.has(part.tool)) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 1a9d907bb97e..4f0d01aa3f67 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -92,8 +92,8 @@ const TIPS = [ "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})", "Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas", - "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools", - 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions', + "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools", + 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', "OpenCode auto-formats files using prettier, gofmt, ruff, and more", @@ -127,7 +127,7 @@ const TIPS = [ "Use {highlight}instructions{/highlight} in config to load additional rules files", "Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)", "Configure {highlight}steps{/highlight} to limit agentic iterations per request", - 'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools', + 'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools', 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server', "Override global tool settings per agent configuration", 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', 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 2599bcfc242d..efb4cd2f9b56 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -35,8 +35,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/shell/bash" -import { ShellTool } from "@/tool/shell/id" +import { ShellTool } from "@/tool/shell/tool" +import { ShellToolID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1514,8 +1514,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1752,7 +1752,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a5723e1d7a77..d9d660b6a425 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -14,7 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@/global" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -284,7 +284,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (ShellTool.has(permission)) { + if (ShellToolID.has(permission)) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2851290f8af5..e47cbfc7c3ee 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -38,6 +38,7 @@ import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Duration, Effect, Layer, Option, ServiceMap } from "effect" +import { ShellToolID } from "@/tool/shell/id" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" @@ -460,10 +461,15 @@ export namespace Config { if (typeof x === "string") return { "*": x as PermissionAction } const obj = x as { __originalKeys?: string[] } & Record const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record + if (!__originalKeys) { + return Object.fromEntries( + Object.entries(rest).map(([key, value]) => [ShellToolID.normalize(key), value as PermissionRule]), + ) + } const result: Record = {} for (const key of __originalKeys) { - if (key in rest) result[key] = rest[key] as PermissionRule + if (!(key in rest)) continue + result[ShellToolID.normalize(key)] = rest[key] as PermissionRule } return result } @@ -479,7 +485,7 @@ export namespace Config { glob: PermissionRule.optional(), grep: PermissionRule.optional(), list: PermissionRule.optional(), - bash: PermissionRule.optional(), + shell: PermissionRule.optional(), task: PermissionRule.optional(), external_directory: PermissionRule.optional(), todowrite: PermissionAction.optional(), @@ -587,8 +593,8 @@ export namespace Config { // write, edit, patch, multiedit all map to edit permission if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action - } else if (tool === "bash") { - permission.bash = action + } else if (ShellToolID.normalize(tool) === ShellToolID.id) { + permission.shell = action } else { permission[tool] = action } diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index 2b0604f4bacc..48c4d5f0828b 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1,4 +1,5 @@ import { Wildcard } from "@/util/wildcard" +import { ShellToolID } from "@/tool/shell/id" type Rule = { permission: string @@ -7,9 +8,10 @@ type Rule = { } export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule { + const next = ShellToolID.normalize(permission) const rules = rulesets.flat() const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + (rule) => Wildcard.match(next, ShellToolID.normalize(rule.permission)) && Wildcard.match(pattern, rule.pattern), ) return match ?? { action: "ask", permission, pattern: "*" } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1ddd9cec324b..f7a072c7dfd2 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -15,6 +15,7 @@ import os from "os" import z from "zod" import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" +import { ShellToolID } from "@/tool/shell/id" export namespace Permission { const log = Log.create({ service: "permission" }) @@ -174,7 +175,9 @@ export namespace Permission { log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + ruleset: ruleset.filter((rule) => + Wildcard.match(ShellToolID.normalize(request.permission), ShellToolID.normalize(rule.permission)), + ), }) } if (rule.action === "allow") continue @@ -290,16 +293,8 @@ export namespace Permission { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] - const bash = permission["bash"] - if (bash !== undefined) { - pushRules(ruleset, "bash", bash) - pushRules(ruleset, "pwsh", bash) - pushRules(ruleset, "powershell", bash) - } - for (const [key, value] of Object.entries(permission)) { - if (key === "bash") continue - pushRules(ruleset, key, value) + pushRules(ruleset, ShellToolID.normalize(key), value) } return ruleset } @@ -313,8 +308,8 @@ export namespace Permission { export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + const permission = EDIT_TOOLS.includes(tool) ? "edit" : ShellToolID.normalize(tool) + const rule = ruleset.findLast((rule) => Wildcard.match(permission, ShellToolID.normalize(rule.permission))) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..b03b755a60a4 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -482,14 +482,15 @@ export namespace Session { const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { + const next = MessageV2.normalizePart(part) yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), + sessionID: next.sessionID, + part: structuredClone(next), time: Date.now(), }), ) - return part + return next as T }).pipe(Effect.withSpan("Session.updatePart")) const create = Effect.fn("Session.create")(function* (input?: { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c9a62c8645e0..08cdbcdb68d6 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -17,6 +17,7 @@ import { Flag } from "@/flag/flag" import { Permission } from "@/permission" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { ShellToolID } from "@/tool/shell/id" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -226,6 +227,12 @@ export namespace LLM { }) } + const repair = (toolName: string) => { + const next = ShellToolID.normalize(toolName.toLowerCase()) + if (!tools[next]) return + return next + } + // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system // and results sent back over the WebSocket. @@ -233,7 +240,7 @@ export namespace LLM { const workflowModel = language workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = tools[toolName] + const t = tools[repair(toolName) ?? toolName] if (!t || !t.execute) { return { result: "", error: `Unknown tool: ${toolName}` } } @@ -262,15 +269,15 @@ export namespace LLM { }) }, async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { + const repaired = repair(failed.toolCall.toolName) + if (repaired && repaired !== failed.toolCall.toolName) { l.info("repairing tool call", { tool: failed.toolCall.toolName, - repaired: lower, + repaired, }) return { ...failed.toolCall, - toolName: lower, + toolName: repaired, } } return { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e8aab62d8423..8d609e59b640 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,6 +15,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" +import { ShellToolID } from "@/tool/shell/id" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -24,6 +25,17 @@ interface FetchDecompressionError extends Error { } export namespace MessageV2 { + export function normalizeTool(tool: string) { + return ShellToolID.normalize(tool) + } + + export function normalizePart(part: T): T { + if (part.type !== "tool") return part + const tool = normalizeTool(part.tool) + if (tool === part.tool) return part + return { ...part, tool } as T + } + export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" } @@ -534,12 +546,12 @@ export namespace MessageV2 { }) as MessageV2.Info const part = (row: typeof PartTable.$inferSelect) => - ({ + normalizePart({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - }) as MessageV2.Part + } as MessageV2.Part) const older = (row: Cursor) => or( @@ -701,7 +713,8 @@ export namespace MessageV2 { role: "assistant", parts: [], } - for (const part of msg.parts) { + for (const raw of msg.parts) { + const part = normalizePart(raw) if (part.type === "text") assistantMessage.parts.push({ type: "text", @@ -874,14 +887,13 @@ export namespace MessageV2 { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) - return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as MessageV2.Part, + return rows.map((row) => + normalizePart({ + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1e26676fc840..23f59bf8a452 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -40,7 +40,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -791,13 +791,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(msg) const sh = Shell.preferred() const name = Shell.name(sh) - const tool = ShellTool.from(name) const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool, + tool: ShellToolID.id, callID: ulid(), state: { status: "running", diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 21d9c0e9f216..e97a282851a2 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -82,7 +82,7 @@ The user will primarily request you perform software engineering tasks. This inc - When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. - If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. - VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. user: Where are errors from the client handled? diff --git a/packages/opencode/src/session/prompt/default.txt b/packages/opencode/src/session/prompt/default.txt index 365663eeef62..e3b4dd940d30 100644 --- a/packages/opencode/src/session/prompt/default.txt +++ b/packages/opencode/src/session/prompt/default.txt @@ -9,7 +9,7 @@ If the user asks for help or wants to give feedback inform them of the following When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. @@ -89,7 +89,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN # Tool usage policy - When doing file search, prefer to use the Task tool in order to reduce context usage. -- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. +- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail. diff --git a/packages/opencode/src/session/prompt/gemini.txt b/packages/opencode/src/session/prompt/gemini.txt index 87fe422bc750..328ce2aaf2ac 100644 --- a/packages/opencode/src/session/prompt/gemini.txt +++ b/packages/opencode/src/session/prompt/gemini.txt @@ -19,18 +19,18 @@ You are opencode, an interactive CLI agent specializing in software engineering When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: 1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have. 2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution. -3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'bash' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). 4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. 5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. ## New Applications -**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'bash'. +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'shell'. 1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. 3. **User Approval:** Obtain user approval for the proposed plan. -4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'bash' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'shell' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. 5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. 6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. @@ -46,13 +46,13 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with 'bash' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with 'shell' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage - **File Paths:** Always use absolute paths when referring to files with tools like 'read' or 'write'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). -- **Command Execution:** Use the 'bash' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -79,7 +79,7 @@ model: [tool_call: ls for path '/path/to/project'] user: start the server implemented in server.js -model: [tool_call: bash for 'node server.js &' because it must run in the background] +model: [tool_call: shell for 'node server.js &' because it must run in the background] @@ -106,7 +106,7 @@ user: Yes model: [tool_call: write or edit to apply the refactoring to 'src/auth.py'] Refactoring complete. Running verification... -[tool_call: bash for 'ruff check src/auth.py && pytest'] +[tool_call: shell for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -125,7 +125,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write to create /path/to/someFile.test.ts with the test code] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: bash for 'npm run test'] +[tool_call: shell for 'npm run test'] diff --git a/packages/opencode/src/session/prompt/gpt.txt b/packages/opencode/src/session/prompt/gpt.txt index 9068df4778dc..4a8f1d3cfd11 100644 --- a/packages/opencode/src/session/prompt/gpt.txt +++ b/packages/opencode/src/session/prompt/gpt.txt @@ -3,7 +3,7 @@ You are OpenCode, You and the user share the same workspace and collaborate to a You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. - When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) -- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. +- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together shell commands with separators like `echo "====";` as this renders to the user poorly. ## Editing Approach diff --git a/packages/opencode/src/session/prompt/kimi.txt b/packages/opencode/src/session/prompt/kimi.txt index beff6755f97d..19461bcfe73b 100644 --- a/packages/opencode/src/session/prompt/kimi.txt +++ b/packages/opencode/src/session/prompt/kimi.txt @@ -30,8 +30,8 @@ When building something from scratch, you should: Always use tools to implement your code changes: - Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. -- Use `bash` to run and test your code after writing it. -- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`. +- Use `shell` to run and test your code after writing it. +- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `shell`. When working on an existing codebase, you should: diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 1806e0eba624..b2113d23191a 100644 --- a/packages/opencode/src/session/prompt/plan.txt +++ b/packages/opencode/src/session/prompt/plan.txt @@ -3,7 +3,7 @@ CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase. STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use sed, tee, echo, cat, -or ANY other bash command to manipulate files - commands may ONLY read/inspect. +or ANY other shell command to manipulate files - commands may ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions, including direct user edit requests. You may ONLY observe, analyze, and plan. Any modification attempt is a critical violation. ZERO exceptions. diff --git a/packages/opencode/src/session/prompt/trinity.txt b/packages/opencode/src/session/prompt/trinity.txt index 28ee4c4f2692..06fb75799efd 100644 --- a/packages/opencode/src/session/prompt/trinity.txt +++ b/packages/opencode/src/session/prompt/trinity.txt @@ -1,7 +1,7 @@ You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6cdf3f697cd5..4e5663bbdbb0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -29,11 +29,8 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { BashTool } from "./shell/bash" -import { ShellTool } from "./shell/id" -import { PwshTool } from "./shell/pwsh" -import { PowershellTool } from "./shell/powershell" -import { Shell } from "@/shell/shell" +import { ShellTool } from "./shell/tool" +import { ShellToolID } from "./shell/id" import { Env } from "../env" import { Question } from "../question" import { Todo } from "../session/todo" @@ -45,7 +42,6 @@ import { Agent } from "../agent/agent" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - const shells = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const type State = { custom: Tool.Def[] @@ -138,14 +134,13 @@ export namespace ToolRegistry { const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - const active = shells[ShellTool.from(Shell.name(Shell.acceptable()))] return { custom, builtin: yield* Effect.forEach( [ InvalidTool, - active, + ShellTool, ReadTool, GlobTool, GrepTool, @@ -176,7 +171,7 @@ export namespace ToolRegistry { const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) { const tools = yield* all() - const match = tools.find((tool) => tool.id === id) + const match = tools.find((tool) => tool.id === ShellToolID.normalize(id)) if (!match) return yield* Effect.die(`Tool not found: ${id}`) return match }) diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index c97d4b8eec39..8610c73f6a4f 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,8 +1,8 @@ -import { ShellTool } from "./id" +import { ShellKind } from "./id" export namespace ShellArity { - export function prefix(tokens: string[], shellType: ShellTool.ID) { - if (ShellTool.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { + export function prefix(tokens: string[], shellType: ShellKind.ID) { + if (ShellKind.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { return [tokens[0]] } for (let len = tokens.length; len > 0; len--) { diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts deleted file mode 100644 index 3a07511b21f7..000000000000 --- a/packages/opencode/src/tool/shell/bash.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createShellTool } from "./util" - -export const BashTool = createShellTool({ - id: "bash", - shellName: "bash", - toolName: "Bash", - listCmd: "ls", - gitCmds: "git bash commands", - chaining: - "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: "", -}) diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts index 0ea57064cf0a..2808d99ee8f9 100644 --- a/packages/opencode/src/tool/shell/id.ts +++ b/packages/opencode/src/tool/shell/id.ts @@ -1,12 +1,12 @@ -export namespace ShellTool { +export namespace ShellKind { export const ids = ["bash", "pwsh", "powershell"] as const export type ID = (typeof ids)[number] - const shell = new Set(ids) + const kind = new Set(ids) const ps = new Set(["pwsh", "powershell"]) export function has(value: string): value is ID { - return shell.has(value) + return kind.has(value) } export function from(value: string): ID { @@ -17,3 +17,19 @@ export namespace ShellTool { return ps.has(value) } } + +export namespace ShellToolID { + export const id = "shell" + export const legacy = "bash" + export type ID = typeof id | typeof legacy + + const tool = new Set([id, legacy]) + + export function has(value: string): value is ID { + return tool.has(value) + } + + export function normalize(value: string) { + return value === legacy ? id : value + } +} diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 7b8dff37b74b..cce46853d57d 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,7 +1,7 @@ import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" -import { ShellTool } from "./id" +import { ShellKind } from "./id" import { Instance } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import path from "path" @@ -165,9 +165,9 @@ export namespace ShellParser { command: string cwd: string shell: string - shellType: ShellTool.ID + shellType: ShellKind.ID }): Promise { - const isPwsh = ShellTool.powershell(opts.shellType) + const isPwsh = ShellKind.powershell(opts.shellType) const parser = isPwsh ? await getPsParser() : await getBashParser() const tree = parser.parse(opts.command) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts deleted file mode 100644 index fe12aadb1402..000000000000 --- a/packages/opencode/src/tool/shell/powershell.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createShellTool } from "./util" - -export const PowershellTool = createShellTool({ - id: "powershell", - shellName: "Windows PowerShell", - toolName: "PowerShell", - listCmd: "Get-ChildItem", - gitCmds: "git commands", - chaining: - "use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", - guidance: `# Windows PowerShell 5.1 shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`).`, -}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts deleted file mode 100644 index 59e9b626adf3..000000000000 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createShellTool } from "./util" - -export const PwshTool = createShellTool({ - id: "pwsh", - shellName: "PowerShell Core", - toolName: "PowerShell", - listCmd: "Get-ChildItem", - gitCmds: "git commands", - chaining: - "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: `# PowerShell 7+ (pwsh) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`).`, -}) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 647605d599cc..8281f036860c 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -2,7 +2,7 @@ import { spawn } from "child_process" import { Shell } from "@/shell/shell" import { Tool } from "../tool" import { Plugin } from "@/plugin" -import { ShellTool } from "./id" +import { ShellKind } from "./id" const MAX_METADATA_LENGTH = 30_000 @@ -27,8 +27,8 @@ exit 1` } } - export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && ShellTool.powershell(name)) { + export function launch(shell: string, kind: ShellKind.ID, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && ShellKind.powershell(kind)) { return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, @@ -51,7 +51,7 @@ exit 1` export async function run( input: { shell: string - name: string + kind: ShellKind.ID command: string cwd: string env: NodeJS.ProcessEnv @@ -60,7 +60,7 @@ exit 1` }, ctx: Tool.Context, ) { - const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) + const proc = launch(input.shell, input.kind, input.command, input.cwd, input.env) let output = "" let code: number | null = null @@ -135,7 +135,7 @@ exit 1` await wait const metadata: string[] = [] - if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) + if (expired) metadata.push(`shell tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") if (metadata.length > 0) { output += "\n\n\n" + metadata.join("\n") + "\n" diff --git a/packages/opencode/src/tool/shell/tool.ts b/packages/opencode/src/tool/shell/tool.ts new file mode 100644 index 000000000000..0b552045c5ad --- /dev/null +++ b/packages/opencode/src/tool/shell/tool.ts @@ -0,0 +1,3 @@ +import { createShellTool } from "./util" + +export const ShellTool = createShellTool() diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index a09289235cc4..f73de280511d 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -108,41 +108,81 @@ export function formatShellDescription( import z from "zod" import DESCRIPTION from "./shell.txt" -import { ShellTool } from "./id" +import { ShellKind, ShellToolID } from "./id" import { Log } from "@/util/log" import { Flag } from "@/flag/flag" import { ShellParser } from "./parser" import { ShellRunner } from "./runner" -export type ShellType = ShellTool.ID - const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -export function createShellTool(opts: { - id: ShellType - shellName: string - chaining: string - guidance: string - listCmd: string - toolName: string - gitCmds: string -}) { - const log = Log.create({ service: `${opts.id}-tool` }) - - return Tool.define(opts.id, async () => { +const info = { + bash: { + shellName: "bash", + listCmd: "ls", + gitCmds: "git commands", + chaining: + "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: "", + }, + pwsh: { + shellName: "PowerShell Core", + listCmd: "Get-ChildItem", + gitCmds: "git commands", + chaining: + "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# PowerShell 7+ (pwsh) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`).`, + }, + powershell: { + shellName: "Windows PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", + chaining: + "use shell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + guidance: `# Windows PowerShell 5.1 shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`).`, + }, +} satisfies Record< + ShellKind.ID, + { + shellName: string + listCmd: string + gitCmds: string + chaining: string + guidance: string + } +> + +export function createShellTool() { + const log = Log.create({ service: "shell-tool" }) + + return Tool.define(ShellToolID.id, async () => { const shell = Shell.acceptable() const name = Shell.name(shell) - log.info(`${opts.id} tool using shell`, { shell, name }) + const kind = ShellKind.from(name) + const cfg = info[kind] + log.info("shell tool using shell", { shell, name, kind }) return { description: formatShellDescription(DESCRIPTION, { name, - shellName: opts.shellName, - chaining: opts.chaining, - guidance: opts.guidance, - listCmd: opts.listCmd, - toolName: opts.toolName, - gitCmds: opts.gitCmds, + shellName: cfg.shellName, + chaining: cfg.chaining, + guidance: cfg.guidance, + listCmd: cfg.listCmd, + toolName: "Shell", + gitCmds: cfg.gitCmds, }), parameters: z.object({ command: z.string().describe("The command to execute"), @@ -170,16 +210,16 @@ export function createShellTool(opts: { command: params.command, cwd, shell, - shellType: opts.id, + shellType: kind, }) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await askPermission(ctx, scan, opts.id) + await askPermission(ctx, scan, ShellToolID.id) return ShellRunner.run( { shell, - name, + kind, command: params.command, cwd, env: await ShellRunner.shellEnv(ctx, cwd), @@ -193,7 +233,7 @@ export function createShellTool(opts: { }) } -export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { +export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = ShellToolID.id) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index a3ae01c5c18b..08b394cfa61e 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -391,7 +391,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_1", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -450,7 +450,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_a", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -509,7 +509,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output }, @@ -541,7 +541,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_bash", - tool: "bash", + tool: "shell", status: "running", input: { command: "echo hi", description: "run command" }, metadata: { output: "hi\n" }, @@ -595,7 +595,7 @@ describe("acp.agent event subscription", () => { { type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "running", input, @@ -612,7 +612,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "hi\nthere\n" }, @@ -646,7 +646,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "a" }, @@ -655,7 +655,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "pending", input, raw: '{"command":"echo hello"}', @@ -664,7 +664,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "a" }, diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 712f9112ea6d..d8532c4215e5 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -172,7 +172,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "ls" }, @@ -183,7 +183,7 @@ describe("transcript", () => { }, } const result = formatPart(part, options) - expect(result).toContain("**Tool: bash**") + expect(result).toContain("**Tool: shell**") expect(result).toContain("**Input:**") expect(result).toContain('"command": "ls"') expect(result).toContain("**Output:**") @@ -197,7 +197,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "echo '```hello```'" }, @@ -209,7 +209,7 @@ describe("transcript", () => { } const result = formatPart(part, options) // The tool header should not be inside a code block - expect(result).toStartWith("**Tool: bash**\n") + expect(result).toStartWith("**Tool: shell**\n") // Input and output should each be in their own code blocks expect(result).toContain("**Input:**\n```json") expect(result).toContain("**Output:**\n```\n```hello```\n```") @@ -222,7 +222,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "ls" }, @@ -233,7 +233,7 @@ describe("transcript", () => { }, } const result = formatPart(part, { ...options, toolDetails: false }) - expect(result).toContain("**Tool: bash**") + expect(result).toContain("**Tool: shell**") expect(result).not.toContain("**Input:**") expect(result).not.toContain("**Output:**") }) @@ -245,7 +245,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "error", input: { command: "invalid" }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0ac61aee7172..fe7cf3191348 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1266,7 +1266,7 @@ test("migrates legacy tools config to permissions - allow", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", read: "allow", }) }, @@ -1297,7 +1297,7 @@ test("migrates legacy tools config to permissions - deny", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "deny", + shell: "deny", webfetch: "deny", }) }, @@ -1524,7 +1524,7 @@ test("migrates mixed legacy tools config", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", edit: "allow", read: "deny", webfetch: "allow", @@ -1560,7 +1560,7 @@ test("merges legacy tools with existing permission config", async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", - bash: "allow", + shell: "allow", }) }, }) @@ -2339,9 +2339,9 @@ test("parseManagedPlist parses permission rules", async () => { expect(config.permission?.grep).toBe("allow") expect(config.permission?.webfetch).toBe("ask") expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const bash = config.permission?.bash as Record - expect(bash?.["rm -rf *"]).toBe("deny") - expect(bash?.["curl *"]).toBe("deny") + const shell = config.permission?.shell as Record + expect(shell?.["rm -rf *"]).toBe("deny") + expect(shell?.["curl *"]).toBe("deny") }) test("parseManagedPlist parses enabled_providers", async () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b98f4a3ae0b3..57733cc0434d 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -34,22 +34,14 @@ async function waitForPending(count: number) { test("fromConfig - string value becomes wildcard rule", () => { const result = Permission.fromConfig({ bash: "allow" }) - expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "*", action: "allow" }, - ]) + expect(result).toEqual([{ permission: "shell", pattern: "*", action: "allow" }]) }) test("fromConfig - object value converts to rules array", () => { const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "rm", action: "deny" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "rm", action: "deny" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "rm", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, + { permission: "shell", pattern: "rm", action: "deny" }, ]) }) @@ -60,39 +52,33 @@ test("fromConfig - mixed string and object values", () => { webfetch: "ask", }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "rm", action: "deny" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "rm", action: "deny" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "rm", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, + { permission: "shell", pattern: "rm", action: "deny" }, { permission: "edit", pattern: "*", action: "allow" }, { permission: "webfetch", pattern: "*", action: "ask" }, ]) }) -test("fromConfig - explicit pwsh overrides bash regardless of key order", () => { +test("fromConfig - shell and legacy bash normalize to shell in key order", () => { const result = Permission.fromConfig({ - pwsh: "deny", + shell: "deny", bash: "allow", }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "deny" }, + { permission: "shell", pattern: "*", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, ]) - expect(Permission.evaluate("pwsh", "ls", result).action).toBe("deny") expect(Permission.evaluate("bash", "ls", result).action).toBe("allow") + expect(Permission.evaluate("shell", "ls", result).action).toBe("allow") }) -test("fromConfig - explicit powershell pattern overrides bash pattern regardless of key order", () => { +test("fromConfig - legacy bash rules coexist with canonical shell rules", () => { const result = Permission.fromConfig({ - powershell: { "rm *": "deny" }, + shell: { "rm *": "deny" }, bash: { "*": "allow", "rm *": "ask" }, }) - expect(Permission.evaluate("powershell", "rm foo", result).action).toBe("deny") - expect(Permission.evaluate("pwsh", "rm foo", result).action).toBe("ask") + expect(Permission.evaluate("shell", "rm foo", result).action).toBe("ask") + expect(Permission.evaluate("bash", "rm foo", result).action).toBe("ask") }) test("fromConfig - empty object", () => { @@ -234,6 +220,11 @@ test("evaluate - exact pattern match", () => { expect(result.action).toBe("deny") }) +test("evaluate - shell matches legacy bash rules", () => { + const result = Permission.evaluate("shell", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + expect(result.action).toBe("deny") +}) + test("evaluate - wildcard pattern match", () => { const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) expect(result.action).toBe("allow") diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0aee396f44a3..41530bd4678b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -797,7 +797,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ], @@ -848,7 +848,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ]) @@ -1125,7 +1125,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => role: "assistant", content: [ { type: "text", text: "" }, - { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, + { type: "tool-call", toolCallId: "123", toolName: "shell", input: { command: "ls" } }, ], }, ] as any[] @@ -1137,7 +1137,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[0]).toEqual({ type: "tool-call", toolCallId: "123", - toolName: "bash", + toolName: "shell", input: { command: "ls" }, }) }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 799bb3e2aeb1..0ada95be0cbc 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -470,7 +470,7 @@ describe("session.compaction.prune", () => { const session = await Session.create({}) const a = await user(session.id, "first") const b = await assistant(session.id, a.id, tmp.path) - await tool(session.id, b.id, "bash", "x".repeat(200_000)) + await tool(session.id, b.id, "shell", "x".repeat(200_000)) await user(session.id, "second") await user(session.id, "third") diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1fa2e61eb241..07fc50ad231e 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -47,7 +47,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-call", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, @@ -63,7 +63,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-result", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7ec8..6cd79c9b725f 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -295,7 +295,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -331,7 +331,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, @@ -344,7 +344,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "content", value: [ @@ -387,7 +387,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -414,7 +414,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -426,7 +426,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "ok" }, }, ], @@ -456,7 +456,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -481,7 +481,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -493,7 +493,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "[Old tool result content cleared]" }, }, ], @@ -523,7 +523,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "error", input: { cmd: "ls" }, @@ -548,7 +548,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, @@ -561,7 +561,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "error-text", value: "nope" }, providerOptions: { openai: { tool: "meta" } }, }, @@ -721,7 +721,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-pending", - tool: "bash", + tool: "shell", state: { status: "pending", input: { cmd: "ls" }, @@ -756,7 +756,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-pending", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -775,7 +775,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-pending", - toolName: "bash", + toolName: "shell", output: { type: "error-text", value: "[Tool execution was interrupted]" }, }, { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 0fc25c1a6b41..0fc72316e5b1 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -550,7 +550,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup Effect.gen(function* () { const { processors, session, provider } = yield* boot() - yield* llm.toolHang("bash", { cmd: "pwd" }) + yield* llm.toolHang("shell", { cmd: "pwd" }) const chat = yield* session.create({}) const parent = yield* user(chat.id, "tool abort") diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 95d90325ad36..0a3428548290 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -59,7 +59,7 @@ function tool(sessionID: string, messageID: string) { messageID: messageID as any, sessionID: sessionID as any, type: "tool" as const, - tool: "bash", + tool: "shell", callID: "call-1", state: { status: "completed" as const, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index cbeaed3289b2..01801279cc37 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -16,12 +16,11 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" -import { Shell } from "../../src/shell/shell" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" -import { ShellTool } from "../../src/tool/shell/id" +import { ShellToolID } from "../../src/tool/shell/id" import { Log } from "../../src/util/log" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -192,7 +191,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - const shell = ShellTool.from(Shell.name(Shell.acceptable())) + const shell = ShellToolID.id // Use the active shell tool to create a file const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index aedfacc3a210..2c587b96aebc 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2,10 +2,8 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/shell/bash" -import { ShellTool } from "../../src/tool/shell/id" -import { PwshTool } from "../../src/tool/shell/pwsh" -import { PowershellTool } from "../../src/tool/shell/powershell" +import { ShellKind, ShellToolID } from "../../src/tool/shell/id" +import { ShellTool } from "../../src/tool/shell/tool" import { ShellRunner } from "../../src/tool/shell/runner" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" @@ -49,14 +47,14 @@ const shells = (() => { (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, ) })() -const ps = shells.filter((item) => ShellTool.powershell(item.label)) +const ps = shells.filter((item) => ShellKind.powershell(item.label)) const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) const js = (code: string, ...args: Array) => { const tail = args.length ? ` ${args.map(String).join(" ")}` : "" const text = `${bin} -e ${evalarg(code)}${tail}` - if (ShellTool.powershell(sh())) return `& ${text}` + if (ShellKind.powershell(sh())) return `& ${text}` return text } @@ -93,12 +91,10 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise ShellTool.from(sh()) - -const tools = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const +const expectedPermission = () => ShellToolID.id const getTool = async () => { - return await tools[ShellTool.from(sh())].init() + return await ShellTool.init() } const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { @@ -158,7 +154,7 @@ describe("tool.shell", () => { }) describe("tool.shell permissions", () => { - each("asks for bash permission with correct pattern", async () => { + each("asks for shell permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -179,7 +175,7 @@ describe("tool.shell permissions", () => { }) }) - each("asks for bash permission with multiple commands", async () => { + each("asks for shell permission with multiple commands", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -256,7 +252,7 @@ describe("tool.shell permissions", () => { if (process.platform === "win32") { if (bash) { test( - "asks for nested bash command permissions [bash]", + "asks for nested shell command permissions [bash]", withShell({ label: "bash", shell: bash }, async () => { await using outerTmp = await tmpdir({ init: async (dir) => { @@ -863,7 +859,7 @@ describe("tool.shell permissions", () => { }) }) - each("does not ask for bash permission when command is cd only", async () => { + each("does not ask for shell permission when command is cd only", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -974,7 +970,7 @@ describe("tool.shell runtime", () => { ShellRunner.run( { shell: item.shell, - name: item.label, + kind: ShellKind.from(item.label), command: js("setTimeout(()=>{},30000)"), cwd: projectRoot, env: process.env, @@ -1007,7 +1003,7 @@ describe("tool.shell runtime", () => { ctx, ) expect(result.output).toContain("222") - expect(result.output).toContain(`${sh()} tool terminated command after exceeding timeout`) + expect(result.output).toContain("shell tool terminated command after exceeding timeout") }, }) }) @@ -1086,7 +1082,7 @@ describe("tool.shell runtime", () => { const result = await ShellRunner.run( { shell: item.shell, - name: item.label, + kind: ShellKind.from(item.label), command: js( "process.stdout.write(Buffer.from([0xF0,0x9F]));setTimeout(()=>process.stdout.write(Buffer.from([0x98,0x80])),20);setTimeout(()=>process.exit(0),40)", ), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a9aa4358ec5..7beb941e4198 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1161,7 +1161,7 @@ export type PermissionConfig = glob?: PermissionRuleConfig grep?: PermissionRuleConfig list?: PermissionRuleConfig - bash?: PermissionRuleConfig + shell?: PermissionRuleConfig task?: PermissionRuleConfig external_directory?: PermissionRuleConfig todowrite?: PermissionActionConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 207b400a7dea..7c8a9e06d3df 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10642,7 +10642,7 @@ "list": { "$ref": "#/components/schemas/PermissionRuleConfig" }, - "bash": { + "shell": { "$ref": "#/components/schemas/PermissionRuleConfig" }, "task": { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 092c2c7e035a..f2b028e9a858 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -271,7 +271,7 @@ export type ToolInfo = { subtitle?: string } -const SHELL = new Set(["bash", "pwsh", "powershell"]) +const SHELL = new Set(["shell"]) function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") @@ -1822,7 +1822,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx index 1780c83ba9dc..308d2dbcbaec 100644 --- a/packages/ui/src/components/shell-submessage-motion.stories.tsx +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -16,7 +16,7 @@ Interactive playground for animating the Shell tool subtitle ("submessage") in t ### Production component path - Trigger layout: \`packages/ui/src/components/basic-tool.tsx\` -- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`) +- Shell tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`shell\`, \`trigger.subtitle\`) ### What this playground tunes - Width reveal (spring-driven pixel width via \`useSpring\`) diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index e79e97a3ab5e..1a5c1efe1e95 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -315,8 +315,8 @@ const TOOL_SAMPLES = { title: "Found 2 matches", metadata: {}, }, - bash: { - tool: "bash", + shell: { + tool: "shell", input: { command: "bun test --filter session", description: "Run session tests" }, output: "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", @@ -1309,7 +1309,7 @@ function Playground() { toolPart(TOOL_SAMPLES.glob), toolPart(TOOL_SAMPLES.grep), toolPart(TOOL_SAMPLES.edit), - toolPart(TOOL_SAMPLES.bash), + toolPart(TOOL_SAMPLES.shell), textPart(MARKDOWN_SAMPLES.mixed), ]) } @@ -1332,7 +1332,7 @@ function Playground() { toolPart(TOOL_SAMPLES.glob), toolPart(TOOL_SAMPLES.grep), toolPart(TOOL_SAMPLES.edit), - toolPart(TOOL_SAMPLES.bash), + toolPart(TOOL_SAMPLES.shell), textPart(MARKDOWN_SAMPLES.blockquote), ]) addContextGroupTurn() diff --git a/packages/ui/src/components/tool-error-card.stories.tsx b/packages/ui/src/components/tool-error-card.stories.tsx index 03349ce011c7..0331ba686290 100644 --- a/packages/ui/src/components/tool-error-card.stories.tsx +++ b/packages/ui/src/components/tool-error-card.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Tool call failure summary styled like a tool trigger. ### API -- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`tool\` (tool id, e.g. apply_patch, shell) - Required: \`error\` (error string) ### Behavior @@ -19,8 +19,8 @@ const samples = [ "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", }, { - tool: "bash", - error: "bash Command failed: exit code 1: bun test --watch", + tool: "shell", + error: "shell Command failed: exit code 1: bun test --watch", }, { tool: "read", @@ -72,7 +72,7 @@ export default { argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], + options: ["apply_patch", "shell", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], }, error: { control: "text", diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 038870d38424..c2dadd98aec1 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -34,6 +34,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", + shell: "ui.tool.shell", bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 3558fd9452e3..a45fed4c3c80 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,7 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 -const SHELL = new Set(["bash", "pwsh", "powershell"]) +const SHELL = new Set(["shell"]) export interface PartProps { index: number From ee0884ad313786ee97969bd42178ac66df394a81 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:14:45 +1000 Subject: [PATCH 26/84] fix(shell): preserve legacy bash compatibility Keep mixed shell/bash permission configs ordered correctly and treat --tools bash as the legacy alias during agent creation. --- packages/opencode/src/cli/cmd/agent.ts | 12 ++++++- packages/opencode/src/config/config.ts | 8 ++--- packages/opencode/test/config/config.test.ts | 34 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index eda4175b9f2b..2b8999bb5968 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -11,6 +11,7 @@ import matter from "gray-matter" import { Instance } from "../../project/instance" import { EOL } from "os" import type { Argv } from "yargs" +import { ShellToolID } from "../../tool/shell/id" type AgentMode = "all" | "primary" | "subagent" @@ -120,7 +121,16 @@ const AgentCreateCommand = cmd({ // Select tools let selectedTools: string[] if (cliTools !== undefined) { - selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS + selectedTools = cliTools + ? [ + ...new Set( + cliTools + .split(",") + .map((t) => ShellToolID.normalize(t.trim())) + .filter(Boolean), + ), + ] + : AVAILABLE_TOOLS } else { const result = await prompts.multiselect({ message: "Select tools to enable (Space to toggle)", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e47cbfc7c3ee..27e6ebe5477f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -461,15 +461,11 @@ export namespace Config { if (typeof x === "string") return { "*": x as PermissionAction } const obj = x as { __originalKeys?: string[] } & Record const { __originalKeys, ...rest } = obj - if (!__originalKeys) { - return Object.fromEntries( - Object.entries(rest).map(([key, value]) => [ShellToolID.normalize(key), value as PermissionRule]), - ) - } + if (!__originalKeys) return rest as Record const result: Record = {} for (const key of __originalKeys) { if (!(key in rest)) continue - result[ShellToolID.normalize(key)] = rest[key] as PermissionRule + result[key] = rest[key] as PermissionRule } return result } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index fe7cf3191348..302374e4e24f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1609,6 +1609,34 @@ test("permission config preserves key order", async () => { }) }) +test("permission config preserves shell and legacy bash order", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + shell: "deny", + bash: "allow", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(Object.keys(config.permission!)).toEqual(["shell", "bash"]) + expect(config.permission).toEqual({ + shell: "deny", + bash: "allow", + }) + }, + }) +}) + // MCP config merging tests test("project config can override MCP server enabled status", async () => { @@ -2339,9 +2367,9 @@ test("parseManagedPlist parses permission rules", async () => { expect(config.permission?.grep).toBe("allow") expect(config.permission?.webfetch).toBe("ask") expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const shell = config.permission?.shell as Record - expect(shell?.["rm -rf *"]).toBe("deny") - expect(shell?.["curl *"]).toBe("deny") + const bash = config.permission?.bash as Record + expect(bash?.["rm -rf *"]).toBe("deny") + expect(bash?.["curl *"]).toBe("deny") }) test("parseManagedPlist parses enabled_providers", async () => { From cb29742b5762354140dbf23e00a6fb851ce1a75f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:46:31 -0500 Subject: [PATCH 27/84] fix(app): remove pierre diff virtualization --- packages/ui/src/components/file-ssr.tsx | 42 ++---- packages/ui/src/components/file.tsx | 124 +----------------- packages/ui/src/components/session-review.tsx | 68 ---------- packages/ui/src/pierre/virtualizer.ts | 100 -------------- 4 files changed, 13 insertions(+), 321 deletions(-) delete mode 100644 packages/ui/src/pierre/virtualizer.ts diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index fed5c8931541..9cf83a4eac87 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -1,4 +1,4 @@ -import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs" import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" @@ -13,7 +13,6 @@ import { notifyShadowReady, observeViewerScheme, } from "../pierre/file-runtime" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { File, type DiffFileProps, type FileProps } from "./file" type DiffPreload = PreloadMultiFileDiffResult | PreloadFileDiffResult @@ -26,7 +25,6 @@ function DiffSSRViewer(props: SSRDiffFileProps) { let container!: HTMLDivElement let fileDiffRef!: HTMLElement let fileDiffInstance: FileDiff | undefined - let sharedVirtualizer: NonNullable> | undefined const ready = createReadyWatcher() const workerPool = useWorkerPool(props.diffStyle) @@ -51,14 +49,6 @@ function DiffSSRViewer(props: SSRDiffFileProps) { const getRoot = () => fileDiffRef?.shadowRoot ?? undefined - const getVirtualizer = () => { - if (sharedVirtualizer) return sharedVirtualizer.virtualizer - const result = acquireVirtualizer(container) - if (!result) return - sharedVirtualizer = result - return result.virtualizer - } - const setSelectedLines = (range: DiffFileProps["selectedLines"], attempt = 0) => { const diff = fileDiffInstance if (!diff) return @@ -92,27 +82,15 @@ function DiffSSRViewer(props: SSRDiffFileProps) { onCleanup(observeViewerScheme(() => fileDiffRef)) - const virtualizer = getVirtualizer() const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] - fileDiffInstance = virtualizer - ? new VirtualizedFileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...(local.preloadedDiff.options ?? {}), - }, - virtualizer, - virtualMetrics, - workerPool, - ) - : new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...(local.preloadedDiff.options ?? {}), - }, - workerPool, - ) + fileDiffInstance = new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...(local.preloadedDiff.options ?? {}), + }, + workerPool, + ) applyViewerScheme(fileDiffRef) @@ -163,8 +141,6 @@ function DiffSSRViewer(props: SSRDiffFileProps) { onCleanup(() => { clearReadyWatcher(ready) fileDiffInstance?.cleanUp() - sharedVirtualizer?.release() - sharedVirtualizer = undefined }) return ( diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index b78f0bae4465..f5e99a8f378c 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -1,6 +1,5 @@ import { sampledChecksum } from "@opencode-ai/util/encode" import { - DEFAULT_VIRTUAL_FILE_METRICS, type DiffLineAnnotation, type FileContents, type FileDiffMetadata, @@ -10,10 +9,6 @@ import { type FileOptions, type LineAnnotation, type SelectedLineRange, - type VirtualFileMetrics, - VirtualizedFile, - VirtualizedFileDiff, - Virtualizer, } from "@pierre/diffs" import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createMediaQuery } from "@solid-primitives/media" @@ -40,19 +35,10 @@ import { readShadowLineSelection, } from "../pierre/file-selection" import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { getWorkerPool } from "../pierre/worker" import { FileMedia, type FileMediaOptions } from "./file-media" import { FileSearchBar } from "./file-search" -const VIRTUALIZE_BYTES = 500_000 - -const codeMetrics = { - ...DEFAULT_VIRTUAL_FILE_METRICS, - lineHeight: 24, - fileGap: 0, -} satisfies Partial - type SharedProps = { annotations?: LineAnnotation[] | DiffLineAnnotation[] selectedLines?: SelectedLineRange | null @@ -386,11 +372,6 @@ type AnnotationTarget = { rerender: () => void } -type VirtualStrategy = { - get: () => Virtualizer | undefined - cleanup: () => void -} - function useModeViewer(config: ModeConfig, adapter: ModeAdapter) { return useFileViewer({ enableLineSelection: config.enableLineSelection, @@ -532,64 +513,6 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined { } } -function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy { - let virtualizer: Virtualizer | undefined - let root: Document | HTMLElement | undefined - - const release = () => { - virtualizer?.cleanUp() - virtualizer = undefined - root = undefined - } - - return { - get: () => { - if (!enabled()) { - release() - return - } - if (typeof document === "undefined") return - - const wrapper = host() - if (!wrapper) return - - const next = scrollParent(wrapper) ?? document - if (virtualizer && root === next) return virtualizer - - release() - virtualizer = new Virtualizer() - root = next - virtualizer.setup(next, next instanceof Document ? undefined : wrapper) - return virtualizer - }, - cleanup: release, - } -} - -function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy { - let shared: NonNullable> | undefined - - const release = () => { - shared?.release() - shared = undefined - } - - return { - get: () => { - if (shared) return shared.virtualizer - - const container = host() - if (!container) return - - const result = acquireVirtualizer(container) - if (!result) return - shared = result - return result.virtualizer - }, - cleanup: release, - } -} - function parseLine(node: HTMLElement) { if (!node.dataset.line) return const value = parseInt(node.dataset.line, 10) @@ -688,7 +611,7 @@ function ViewerShell(props: { // --------------------------------------------------------------------------- function TextViewer(props: TextFileProps) { - let instance: PierreFile | VirtualizedFile | undefined + let instance: PierreFile | undefined let viewer!: Viewer const [local, others] = splitProps(props, textKeys) @@ -707,34 +630,12 @@ function TextViewer(props: TextFileProps) { return Math.max(1, total) } - const bytes = createMemo(() => { - const value = local.file.contents as unknown - if (typeof value === "string") return value.length - if (Array.isArray(value)) { - return value.reduce( - (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1), - 0, - ) - } - if (value == null) return 0 - return String(value).length - }) - - const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES) - - const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual) - const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine) const applySelection = (range: SelectedLineRange | null) => { const current = instance if (!current) return false - if (virtual()) { - current.setSelectedLines(range) - return true - } - const root = viewer.getRoot() if (!root) return false @@ -833,10 +734,7 @@ function TextViewer(props: TextFileProps) { const notify = () => { notifyRendered({ viewer, - isReady: (root) => { - if (virtual()) return root.querySelector("[data-line]") != null - return root.querySelectorAll("[data-line]").length >= lineCount() - }, + isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(), onReady: () => { applySelection(viewer.lastSelection) viewer.find.refresh({ reset: true }) @@ -855,17 +753,11 @@ function TextViewer(props: TextFileProps) { createEffect(() => { const opts = options() const workerPool = getWorkerPool("unified") - const isVirtual = virtual() - - const virtualizer = virtuals.get() renderViewer({ viewer, current: instance, - create: () => - isVirtual && virtualizer - ? new VirtualizedFile(opts, virtualizer, codeMetrics, workerPool) - : new PierreFile(opts, workerPool), + create: () => new PierreFile(opts, workerPool), assign: (value) => { instance = value }, @@ -892,7 +784,6 @@ function TextViewer(props: TextFileProps) { onCleanup(() => { instance?.cleanUp() instance = undefined - virtuals.cleanup() }) return @@ -988,8 +879,6 @@ function DiffViewer(props: DiffFileProps) { adapter, ) - const virtuals = createSharedVirtualStrategy(() => viewer.container) - const large = createMemo(() => { if (local.fileDiff) { const before = local.fileDiff.deletionLines.join("") @@ -1052,7 +941,6 @@ function DiffViewer(props: DiffFileProps) { createEffect(() => { const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) - const virtualizer = virtuals.get() const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" const done = preserve(viewer) @@ -1067,10 +955,7 @@ function DiffViewer(props: DiffFileProps) { renderViewer({ viewer, current: instance, - create: () => - virtualizer - ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) - : new FileDiff(opts, workerPool), + create: () => new FileDiff(opts, workerPool), assign: (value) => { instance = value }, @@ -1108,7 +993,6 @@ function DiffViewer(props: DiffFileProps) { onCleanup(() => { instance?.cleanUp() instance = undefined - virtuals.cleanup() dragSide = undefined dragEndSide = undefined }) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 90da853efc1a..2d2776fd32f0 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -26,7 +26,6 @@ import type { LineCommentEditorProps } from "./line-comment" import { normalize, text, type ViewDiff } from "./session-diff" const MAX_DIFF_CHANGED_LINES = 500 -const REVIEW_MOUNT_MARGIN = 300 export type SessionReviewDiffStyle = "unified" | "split" @@ -139,14 +138,11 @@ type SessionReviewSelection = { export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let focusToken = 0 - let frame: number | undefined const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() - const nodes = new Map() const [store, setStore] = createStore({ open: [] as string[], - visible: {} as Record, force: {} as Record, selection: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null, @@ -174,44 +170,7 @@ export const SessionReview = (props: SessionReviewProps) => { const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 - const syncVisible = () => { - frame = undefined - if (!scroll) return - - const root = scroll.getBoundingClientRect() - const top = root.top - REVIEW_MOUNT_MARGIN - const bottom = root.bottom + REVIEW_MOUNT_MARGIN - const openSet = new Set(open()) - const next: Record = {} - - for (const [file, el] of nodes) { - if (!openSet.has(file)) continue - const rect = el.getBoundingClientRect() - if (rect.bottom < top || rect.top > bottom) continue - next[file] = true - } - - const prev = untrack(() => store.visible) - const prevKeys = Object.keys(prev) - const nextKeys = Object.keys(next) - if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return - setStore("visible", next) - } - - const queue = () => { - if (frame !== undefined) return - frame = requestAnimationFrame(syncVisible) - } - - const pinned = (file: string) => - props.focusedComment?.file === file || - props.focusedFile === file || - selection()?.file === file || - commenting()?.file === file || - opened()?.file === file - const handleScroll: JSX.EventHandler = (event) => { - queue() const next = props.onScroll if (!next) return if (Array.isArray(next)) { @@ -222,21 +181,9 @@ export const SessionReview = (props: SessionReviewProps) => { ;(next as JSX.EventHandler)(event) } - onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) - }) - - createEffect(() => { - props.open - files() - queue() - }) - const handleChange = (next: string[]) => { props.onOpenChange?.(next) if (props.open === undefined) setStore("open", next) - queue() } const handleExpandOrCollapseAll = () => { @@ -350,7 +297,6 @@ export const SessionReview = (props: SessionReviewProps) => { viewportRef={(el) => { scroll = el props.scrollRef?.(el) - queue() }} onScroll={handleScroll} classList={{ @@ -363,11 +309,9 @@ export const SessionReview = (props: SessionReviewProps) => { {(diff) => { - let wrapper: HTMLDivElement | undefined const file = diff.file const expanded = createMemo(() => open().includes(file)) - const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] const comments = createMemo(() => grouped().get(file) ?? []) @@ -458,8 +402,6 @@ export const SessionReview = (props: SessionReviewProps) => { onCleanup(() => { anchors.delete(file) - nodes.delete(file) - queue() }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -542,21 +484,11 @@ export const SessionReview = (props: SessionReviewProps) => {
{ - wrapper = el anchors.set(file, el) - nodes.set(file, el) - queue() }} > - -
-
diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts deleted file mode 100644 index 31862cc49316..000000000000 --- a/packages/ui/src/pierre/virtualizer.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs" - -type Target = { - key: Document | HTMLElement - root: Document | HTMLElement - content: HTMLElement | undefined -} - -type Entry = { - virtualizer: Virtualizer - refs: number -} - -const cache = new WeakMap() - -export const virtualMetrics: Partial = { - lineHeight: 24, - hunkSeparatorHeight: 24, - fileGap: 0, -} - -function scrollable(value: string) { - return value === "auto" || value === "scroll" || value === "overlay" -} - -function scrollRoot(container: HTMLElement) { - let node = container.parentElement - while (node) { - const style = getComputedStyle(node) - if (scrollable(style.overflowY)) return node - node = node.parentElement - } -} - -function target(container: HTMLElement): Target | undefined { - if (typeof document === "undefined") return - - const review = container.closest("[data-component='session-review']") - if (review instanceof HTMLElement) { - const root = scrollRoot(container) ?? review - const content = review.querySelector("[data-slot='session-review-container']") - return { - key: review, - root, - content: content instanceof HTMLElement ? content : undefined, - } - } - - const root = scrollRoot(container) - if (root) { - const content = root.querySelector("[role='log']") - return { - key: root, - root, - content: content instanceof HTMLElement ? content : undefined, - } - } - - return { - key: document, - root: document, - content: undefined, - } -} - -export function acquireVirtualizer(container: HTMLElement) { - const resolved = target(container) - if (!resolved) return - - let entry = cache.get(resolved.key) - if (!entry) { - const virtualizer = new Virtualizer() - virtualizer.setup(resolved.root, resolved.content) - entry = { - virtualizer, - refs: 0, - } - cache.set(resolved.key, entry) - } - - entry.refs += 1 - let done = false - - return { - virtualizer: entry.virtualizer, - release() { - if (done) return - done = true - - const current = cache.get(resolved.key) - if (!current) return - - current.refs -= 1 - if (current.refs > 0) return - - current.virtualizer.cleanUp() - cache.delete(resolved.key) - }, - } -} From 88ec184fe698314e2e7414b1ae7602fdcc3dcde7 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 27 Feb 2026 08:24:29 +0800 Subject: [PATCH 28/84] sentry integration --- .github/workflows/deploy.yml | 7 ++ .github/workflows/publish.yml | 7 ++ bun.lock | 112 +++++++++++++++++++++++++++++--- package.json | 2 + packages/app/package.json | 2 + packages/app/src/app.tsx | 8 ++- packages/app/src/entry.tsx | 14 ++++ packages/app/src/env.d.ts | 4 ++ packages/app/vite.config.ts | 22 ++++++- packages/desktop/package.json | 2 + packages/desktop/src/env.d.ts | 9 +++ packages/desktop/src/index.tsx | 14 ++++ packages/desktop/vite.config.ts | 25 +++++-- 13 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 packages/desktop/src/env.d.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 96f437a73fca..fb860dde9f5b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,10 @@ jobs: PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: web-${{ github.ref_name }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af008f6b17e3..0a16b144155d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -368,6 +368,13 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: desktop-${{ github.ref_name }} + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Verify signed Windows desktop artifacts if: runner.os == 'Windows' diff --git a/bun.lock b/bun.lock index 63232cb29e81..eb1d32351cc1 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -69,6 +70,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -196,6 +198,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -216,6 +219,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -677,6 +681,8 @@ "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1944,6 +1950,44 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="], + + "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="], + + "@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="], + + "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="], + + "@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="], + + "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="], + + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="], + + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="], + + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="], + + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="], + + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="], + + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="], + + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="], + + "@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="], + + "@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="], + + "@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="], + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], @@ -3228,7 +3272,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3722,7 +3766,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4126,7 +4170,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -4176,7 +4220,7 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -4938,7 +4982,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], @@ -5046,7 +5090,9 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -5600,6 +5646,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -5650,6 +5706,8 @@ "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -5834,8 +5892,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -5942,7 +5998,7 @@ "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -5954,6 +6010,8 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6050,6 +6108,10 @@ "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "venice-ai-sdk-provider/@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=="], @@ -6550,6 +6612,16 @@ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6568,6 +6640,8 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -6696,8 +6770,12 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -6726,6 +6804,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -6950,6 +7030,12 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7034,6 +7120,8 @@ "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], @@ -7046,6 +7134,8 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7100,6 +7190,8 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -7126,6 +7218,8 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 5fecc0992202..44391ff2c5f9 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" diff --git a/packages/app/package.json b/packages/app/package.json index 2941637d089b..1b51ce002567 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -40,6 +41,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@sentry/solid": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/shared": "workspace:*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index dbe107448499..9e2b22235a10 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,4 +1,5 @@ import "@/index.css" +import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -140,7 +141,12 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - }> + { + Sentry.captureException(error) + return + }} + > diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d3..318329ff2f61 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,5 +1,6 @@ // @refresh reload +import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" @@ -125,6 +126,19 @@ const platform: Platform = { setDefaultServer: writeDefaultServerUrl, } +if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`, + initialScope: { + tags: { + platform: "web", + }, + }, + }) +} + if (root instanceof HTMLElement) { const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } render( diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 22e52f9919fb..104c6a306ea5 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -4,6 +4,10 @@ interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 6a29ae6345e0..8df324ddc916 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,8 +1,26 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "vite" import desktopPlugin from "./vite" +const sentry = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + telemetry: false, + release: { + name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + }, + sourcemaps: { + assets: "./dist/**", + filesToDeleteAfterUpload: "./dist/**/*.map", + }, + }) + : false + export default defineConfig({ - plugins: [desktopPlugin] as any, + plugins: [desktopPlugin, sentry] as any, server: { host: "0.0.0.0", allowedHosts: true, @@ -10,6 +28,6 @@ export default defineConfig({ }, build: { target: "esnext", - // sourcemap: true, + sourcemap: true, }, }) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d8eea4ea36ce..63a5311f9edb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", @@ -35,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts new file mode 100644 index 000000000000..aff0168422da --- /dev/null +++ b/packages/desktop/src/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d6a0ad74f801..59eef25924bb 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" @@ -42,6 +43,19 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) } +if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-tauri@${pkg.version}`, + initialScope: { + tags: { + platform: "desktop-tauri", + }, + }, + }) +} + void initI18n() let update: Update | null = null diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 62c3a099adeb..f2c77de9b373 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -1,11 +1,28 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "vite" import appPlugin from "@opencode-ai/app/vite" const host = process.env.TAURI_DEV_HOST +const sentry = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + telemetry: false, + release: { + name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + }, + sourcemaps: { + assets: "./dist/**", + filesToDeleteAfterUpload: "./dist/**/*.map", + }, + }) + : false // https://vite.dev/config/ export default defineConfig({ - plugins: [appPlugin], + plugins: [appPlugin, sentry], publicDir: "../app/public", // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // @@ -15,9 +32,9 @@ export default defineConfig({ // Improves production stack traces keepNames: true, }, - // build: { - // sourcemap: true, - // }, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, From 32b97f74580b1e1a8bc8092d51922073379d782c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 20 Mar 2026 21:38:10 +0800 Subject: [PATCH 29/84] electron sentry integration --- bun.lock | 2 ++ .../desktop-electron/electron.vite.config.ts | 21 ++++++++++++++++++- packages/desktop-electron/package.json | 2 ++ .../desktop-electron/src/renderer/index.tsx | 14 +++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index eb1d32351cc1..bcffe9338eb0 100644 --- a/bun.lock +++ b/bun.lock @@ -244,6 +244,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", + "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index d0e6c42b6c8c..33b9d066b68a 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -1,3 +1,4 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "electron-vite" import appPlugin from "@opencode-ai/app/vite" import * as fs from "node:fs/promises" @@ -12,6 +13,23 @@ const OPENCODE_SERVER_DIST = "../opencode/dist/node" const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` +const sentry = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + telemetry: false, + release: { + name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + }, + sourcemaps: { + assets: "./out/renderer/**", + filesToDeleteAfterUpload: "./out/renderer/**/*.map", + }, + }) + : false + export default defineConfig({ main: { define: { @@ -57,13 +75,14 @@ export default defineConfig({ }, }, renderer: { - plugins: [appPlugin], + plugins: [appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", define: { "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, build: { + sourcemap: true, rollupOptions: { input: { main: "src/renderer/index.html", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index e1f69b5b2088..96e03e2ea4b2 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -37,6 +37,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", + "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 44f2e6360c35..45f8eb22e420 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" @@ -30,6 +31,19 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) } +if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-electron@${pkg.version}`, + initialScope: { + tags: { + platform: "desktop-electron", + }, + }, + }) +} + void initI18n() const deepLinkEvent = "opencode:deep-link" From 8854cbf9fe28bae99d8771ee4a20e2848b0d9176 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 14:57:56 +0800 Subject: [PATCH 30/84] focus on electron --- .github/workflows/deploy.yml | 5 ++--- .github/workflows/publish.yml | 14 +++++++------- packages/app/src/entry.tsx | 8 +++++++- .../desktop-electron/src/renderer/index.tsx | 10 +++++++++- packages/desktop/src/index.tsx | 13 ------------- packages/desktop/vite.config.ts | 19 +------------------ 6 files changed, 26 insertions(+), 43 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fb860dde9f5b..4cd63fc0e790 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,8 +38,7 @@ jobs: STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} SENTRY_RELEASE: web@${{ github.sha }} - VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - VITE_SENTRY_ENVIRONMENT: web-${{ github.ref_name }} + VITE_SENTRY_DSN: ${{ secrets.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a16b144155d..8dd86d4edde6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -368,13 +368,6 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} - SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - VITE_SENTRY_ENVIRONMENT: desktop-${{ github.ref_name }} - VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Verify signed Windows desktop artifacts if: runner.os == 'Windows' @@ -497,6 +490,13 @@ jobs: working-directory: packages/desktop-electron env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ secrets.WEB_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Package and publish if: needs.version.outputs.release diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 318329ff2f61..ade572c2fd50 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -126,7 +126,7 @@ const platform: Platform = { setDefaultServer: writeDefaultServerUrl, } -if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { +if (import.meta.env.VITE_SENTRY_DSN) { Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, @@ -136,6 +136,12 @@ if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { platform: "web", }, }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, }) } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 45f8eb22e420..1a1cba66673a 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -31,7 +31,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) } -if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { +if (import.meta.env.VITE_SENTRY_DSN) { Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, @@ -41,6 +41,12 @@ if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { platform: "desktop-electron", }, }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, }) } @@ -326,6 +332,8 @@ render(() => { } }) + throw new Error("Test2") + return null } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 59eef25924bb..fcdaf79642c0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -43,19 +43,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) } -if (!import.meta.env.DEV && import.meta.env.VITE_SENTRY_DSN) { - Sentry.init({ - dsn: import.meta.env.VITE_SENTRY_DSN, - environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, - release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-tauri@${pkg.version}`, - initialScope: { - tags: { - platform: "desktop-tauri", - }, - }, - }) -} - void initI18n() let update: Update | null = null diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index f2c77de9b373..e8f8f8465d05 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -1,28 +1,11 @@ -import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "vite" import appPlugin from "@opencode-ai/app/vite" const host = process.env.TAURI_DEV_HOST -const sentry = - process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT - ? sentryVitePlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - telemetry: false, - release: { - name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, - }, - sourcemaps: { - assets: "./dist/**", - filesToDeleteAfterUpload: "./dist/**/*.map", - }, - }) - : false // https://vite.dev/config/ export default defineConfig({ - plugins: [appPlugin, sentry], + plugins: [appPlugin], publicDir: "../app/public", // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // From 7bc884568c6bd271279aaa4f724141391d9de839 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 16 Apr 2026 17:53:20 -0400 Subject: [PATCH 31/84] fix(desktop-electron): remove temporary Test2 throw from beta renderer --- packages/desktop-electron/src/renderer/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 1a1cba66673a..ad5e00f6d7d6 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -332,8 +332,6 @@ render(() => { } }) - throw new Error("Test2") - return null } From 264070cb5318efded4f955f56a8007fa5cb22cd4 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 17 Apr 2026 10:15:14 +0800 Subject: [PATCH 32/84] use vars not secrets --- .github/workflows/deploy.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4cd63fc0e790..e346d0cd5c69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,5 +40,5 @@ jobs: SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} SENTRY_RELEASE: web@${{ github.sha }} - VITE_SENTRY_DSN: ${{ secrets.WEB_SENTRY_DSN }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8dd86d4edde6..141b5805ed5b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -494,7 +494,7 @@ jobs: SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - VITE_SENTRY_DSN: ${{ secrets.WEB_SENTRY_DSN }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} From 14eacb40192be0b14e7f8d6273c3a735a5bdd255 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 21:07:58 +0800 Subject: [PATCH 33/84] core: move plugin intialisation to config layer override --- packages/opencode/src/effect/app-runtime.ts | 19 ++++++++++++++++++- packages/opencode/src/project/bootstrap.ts | 6 ------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index eae52d6366db..17be3ac634d2 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,6 +47,23 @@ import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" import { Npm } from "@opencode-ai/shared/npm" +import * as Effect from "effect/Effect" + +// Adjusts the default Config layer to ensure that plugins are always initialised before +// any other layers read the current config +const PluginPriorityConfigLayer = Layer.unwrap( + Effect.gen(function* () { + const configSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + + return Layer.succeed(Config.Service, { + ...configSvc, + get: () => Effect.andThen(pluginSvc.init(), configSvc.get), + getGlobal: () => Effect.andThen(pluginSvc.init(), configSvc.getGlobal), + getConsoleState: () => Effect.andThen(pluginSvc.init(), configSvc.getConsoleState), + }) + }), +).pipe(Layer.provideMerge(Layer.merge(Plugin.defaultLayer, Config.defaultLayer))) export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -54,7 +71,7 @@ export const AppLayer = Layer.mergeAll( Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, - Config.defaultLayer, + PluginPriorityConfigLayer, Git.defaultLayer, Ripgrep.defaultLayer, File.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a7c071a9f80b..012b10ef8695 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,4 +1,3 @@ -import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" @@ -12,14 +11,9 @@ import { Log } from "@/util" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share" import * as Effect from "effect/Effect" -import { Config } from "@/config" export const InstanceBootstrap = Effect.gen(function* () { Log.Default.info("bootstrapping", { directory: Instance.directory }) - // everything depends on config so eager load it for nice traces - yield* Config.Service.use((svc) => svc.get()) - // Plugin can mutate config so it has to be initialized before anything else. - yield* Plugin.Service.use((svc) => svc.init()) yield* Effect.all( [ LSP.Service, From e287569f82c3935415db4c18c2bda915280a63ef Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 21:19:59 +0800 Subject: [PATCH 34/84] rename layer --- packages/opencode/src/effect/app-runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 17be3ac634d2..b96af2fa5a99 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -51,7 +51,7 @@ import * as Effect from "effect/Effect" // Adjusts the default Config layer to ensure that plugins are always initialised before // any other layers read the current config -const PluginPriorityConfigLayer = Layer.unwrap( +const ConfigWithPluginPriority = Layer.unwrap( Effect.gen(function* () { const configSvc = yield* Config.Service const pluginSvc = yield* Plugin.Service @@ -71,7 +71,7 @@ export const AppLayer = Layer.mergeAll( Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, - PluginPriorityConfigLayer, + ConfigWithPluginPriority, Git.defaultLayer, Ripgrep.defaultLayer, File.defaultLayer, From dc6d39551c51e7d514725f0161e3158d130b256b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 06:36:34 +0800 Subject: [PATCH 35/84] address feedback --- packages/opencode/src/effect/app-runtime.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index b96af2fa5a99..d5e76cb5b7c0 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -51,19 +51,20 @@ import * as Effect from "effect/Effect" // Adjusts the default Config layer to ensure that plugins are always initialised before // any other layers read the current config -const ConfigWithPluginPriority = Layer.unwrap( +const ConfigWithPluginPriority = Layer.effect( + Config.Service, Effect.gen(function* () { - const configSvc = yield* Config.Service - const pluginSvc = yield* Plugin.Service + const config = yield* Config.Service + const plugin = yield* Plugin.Service - return Layer.succeed(Config.Service, { - ...configSvc, - get: () => Effect.andThen(pluginSvc.init(), configSvc.get), - getGlobal: () => Effect.andThen(pluginSvc.init(), configSvc.getGlobal), - getConsoleState: () => Effect.andThen(pluginSvc.init(), configSvc.getConsoleState), - }) + return { + ...config, + get: () => Effect.andThen(plugin.init(), config.get), + getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal), + getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState), + } }), -).pipe(Layer.provideMerge(Layer.merge(Plugin.defaultLayer, Config.defaultLayer))) +).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer))) export const AppLayer = Layer.mergeAll( Npm.defaultLayer, From 031766efa030800caee79d1ea745e9ab95689555 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 10:54:55 +0800 Subject: [PATCH 36/84] fix tui --- packages/opencode/src/cli/cmd/tui/thread.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 96ceb905c5ff..f1b94a2ce988 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -8,6 +8,7 @@ import { UI } from "@/cli/ui" import { Log } from "@/util" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" +import { Instance } from "@/project/instance" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util" import type { GlobalEvent } from "@opencode-ai/sdk/v2" @@ -178,7 +179,11 @@ export const TuiThreadCommand = cmd({ const prompt = await input(args.prompt) const config = await TuiConfig.get() - const network = resolveNetworkOptionsNoConfig(args) + const network = await Instance.provide({ + directory: cwd, + fn: () => resolveNetworkOptionsNoConfig(args), + }) + const external = process.argv.includes("--port") || process.argv.includes("--hostname") || From b1db69fdf76000312068b9322fa0fc6ff93014f2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 15:05:40 +0800 Subject: [PATCH 37/84] fix other commands --- packages/opencode/src/cli/cmd/serve.ts | 4 +++- packages/opencode/src/cli/cmd/web.ts | 3 ++- packages/opencode/src/project/bootstrap.ts | 22 ++++++++++++++-------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index d5eee75dd18e..ea2f717f7cc3 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,6 +2,7 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" +import { bootstrap } from "../bootstrap" export const ServeCommand = cmd({ command: "serve", @@ -11,7 +12,8 @@ export const ServeCommand = cmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) + + const opts = await bootstrap(process.cwd(), () => resolveNetworkOptions(args)) const server = await Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 9dd8796d6e94..30a4641fef2d 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" +import { bootstrap } from "../bootstrap" function getNetworkIPs() { const nets = networkInterfaces() @@ -36,7 +37,7 @@ export const WebCommand = cmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) + const opts = await bootstrap(process.cwd(), () => resolveNetworkOptions(args)) const server = await Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 012b10ef8695..de13f162f7cd 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -7,23 +7,29 @@ import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" +import { Plugin } from "../plugin" import { Log } from "@/util" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share" import * as Effect from "effect/Effect" +import { Config } from "@/config" export const InstanceBootstrap = Effect.gen(function* () { Log.Default.info("bootstrapping", { directory: Instance.directory }) yield* Effect.all( [ - LSP.Service, - ShareNext.Service, - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), + Config.Service.use((i) => i.get()), + ...[ + Plugin.Service, + LSP.Service, + ShareNext.Service, + Format.Service, + File.Service, + FileWatcher.Service, + Vcs.Service, + Snapshot.Service, + ].map((s) => s.use((i) => i.init())), + ].map((e) => Effect.forkDetach(e)), ).pipe(Effect.withSpan("InstanceBootstrap.init")) yield* Bus.Service.use((svc) => From f280e7e69ce7ffb046af331afb3433822063460a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:08:42 +1000 Subject: [PATCH 38/84] fix: defer MessageV2.Assistant.shape access to break circular dep in compiled binary --- packages/opencode/src/session/session.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5168b80b563d..ba144da9f030 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -246,7 +246,8 @@ export const Event = { "session.error", z.object({ sessionID: SessionID.zod.optional(), - error: MessageV2.Assistant.shape.error, + // z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session + error: z.lazy(() => MessageV2.Assistant.shape.error), }), ), } From b75f831eaa05289f926167b0614621439eddcbab Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:34:57 +1000 Subject: [PATCH 39/84] . --- packages/opencode/src/config/config.ts | 31 -------------------- packages/opencode/test/config/config.test.ts | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9316994f13ca..979fa251420d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -44,7 +44,6 @@ import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" import { ShellToolID } from "@/tool/shell/id" -import { makeRuntime } from "@/effect/run-service" const log = Log.create({ service: "config" }) @@ -802,33 +801,3 @@ export const defaultLayer = layer.pipe( Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), ) - -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function get() { - return runPromise((svc) => svc.get()) -} - -export async function getGlobal() { - return runPromise((svc) => svc.getGlobal()) -} - -export async function update(...args: Parameters) { - return runPromise((svc) => svc.update(...args)) -} - -export async function updateGlobal(...args: Parameters) { - return runPromise((svc) => svc.updateGlobal(...args)) -} - -export async function invalidate(...args: Parameters) { - return runPromise((svc) => svc.invalidate(...args)) -} - -export async function directories() { - return runPromise((svc) => svc.directories()) -} - -export async function waitForDependencies() { - return runPromise((svc) => svc.waitForDependencies()) -} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 3f4bfbfa184e..3126836dc9b2 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1568,7 +1568,7 @@ test("permission config preserves shell and legacy bash order", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(Object.keys(config.permission!)).toEqual(["shell", "bash"]) expect(config.permission).toEqual({ shell: "deny", From 3e30068907d98e7e3e9ce01297224ca9b407fd18 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:46:00 +1000 Subject: [PATCH 40/84] refactor: make shell the canonical tool internals --- packages/opencode/src/cli/cmd/agent.ts | 4 +--- packages/opencode/src/cli/cmd/github.ts | 1 - packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/session/llm.ts | 3 +-- packages/opencode/src/session/message-v2.ts | 19 +++------------ packages/opencode/src/tool/registry.ts | 2 +- .../opencode/src/tool/{bash.ts => shell.ts} | 18 +++++++------- packages/opencode/src/tool/shell/id.ts | 6 ++--- packages/opencode/src/tool/shell/tool.ts | 1 - packages/opencode/src/tool/task.ts | 3 +-- .../opencode/test/session/message-v2.test.ts | 6 ++--- .../session/session-entry-stepper.test.ts | 24 +++++++++---------- packages/opencode/test/tool/shell.test.ts | 2 +- packages/opencode/test/tool/task.test.ts | 2 +- .../ui/src/components/tool-error-card.tsx | 1 - 16 files changed, 36 insertions(+), 60 deletions(-) rename packages/opencode/src/tool/{bash.ts => shell.ts} (96%) delete mode 100644 packages/opencode/src/tool/shell/tool.ts diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index f8cbae42fc45..dee5fea7ac9a 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -12,8 +12,6 @@ import matter from "gray-matter" import { Instance } from "../../project/instance" import { EOL } from "os" import type { Argv } from "yargs" -import { ShellToolID } from "../../tool/shell/id" - type AgentMode = "all" | "primary" | "subagent" const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] @@ -129,7 +127,7 @@ const AgentCreateCommand = cmd({ ...new Set( cliTools .split(",") - .map((t) => ShellToolID.normalize(t.trim())) + .map((t) => t.trim()) .filter(Boolean), ), ] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ba33cbd81e3d..00291daf9f37 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -880,7 +880,6 @@ export const GithubRunCommand = cmd({ const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], shell: ["Shell", UI.Style.TEXT_DANGER_BOLD], - bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f63d762ae005..06cfda542dfd 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -23,7 +23,7 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell/tool" +import { ShellTool } from "../../tool/shell" import { ShellToolID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util" 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 86ad2ca98640..31f356137349 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,7 @@ import { Locale } from "@/util" import type { Tool } from "@/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { ShellTool } from "@/tool/shell/tool" +import { ShellTool } from "@/tool/shell" import { ShellToolID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0824cba426e7..848d06c888c9 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,7 +22,6 @@ import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" -import { ShellToolID } from "@/tool/shell/id" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -208,7 +207,7 @@ const live: Layer.Layer< input.model.api.id.toLowerCase().includes("litellm") const repair = (toolName: string) => { - const next = ShellToolID.normalize(toolName.toLowerCase()) + const next = toolName.toLowerCase() if (!tools[next]) return return next } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2b66fc285c76..c6d2c2be1fe2 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -16,7 +16,6 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { ShellToolID } from "@/tool/shell/id" import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" @@ -443,17 +442,6 @@ export type Part = | RetryPart | CompactionPart -function normalizeTool(tool: string) { - return ShellToolID.normalize(tool) -} - -function normalizePart(part: T): T { - if (part.type !== "tool") return part - const tool = normalizeTool(part.tool) - if (tool === part.tool) return part - return { ...part, tool } as T -} - // Errors are still NamedError-based Zod; bridge via ZodOverride so the derived // Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the // error classes to Schema.TaggedErrorClass is a separate slice. @@ -673,12 +661,12 @@ const info = (row: typeof MessageTable.$inferSelect) => }) as Info const part = (row: typeof PartTable.$inferSelect) => - normalizePart(({ + ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - } as Part)) + } as Part) const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) @@ -843,8 +831,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } - for (const raw of msg.parts) { - const part = normalizePart(raw) + for (const part of msg.parts) { if (part.type === "text") assistantMessage.parts.push({ type: "text", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index aa27e900e5ab..0c1289a78bac 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "../session" import { QuestionTool } from "./question" -import { ShellTool } from "./shell/tool" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 96% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index 8bf86303dd78..952362995907 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -253,13 +253,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree.rootNode }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -336,7 +336,7 @@ export const ShellTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -345,7 +345,7 @@ export const ShellTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -356,7 +356,7 @@ export const ShellTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -365,7 +365,7 @@ export const ShellTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("ShellTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -396,7 +396,7 @@ export const ShellTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -408,7 +408,7 @@ export const ShellTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string name: string @@ -644,5 +644,3 @@ export const ShellTool = Tool.define( }) }), ) - -export const BashTool = ShellTool diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts index 2808d99ee8f9..60673dd698df 100644 --- a/packages/opencode/src/tool/shell/id.ts +++ b/packages/opencode/src/tool/shell/id.ts @@ -21,12 +21,10 @@ export namespace ShellKind { export namespace ShellToolID { export const id = "shell" export const legacy = "bash" - export type ID = typeof id | typeof legacy - - const tool = new Set([id, legacy]) + export type ID = typeof id export function has(value: string): value is ID { - return tool.has(value) + return value === id } export function normalize(value: string) { diff --git a/packages/opencode/src/tool/shell/tool.ts b/packages/opencode/src/tool/shell/tool.ts deleted file mode 100644 index b3af7377a85c..000000000000 --- a/packages/opencode/src/tool/shell/tool.ts +++ /dev/null @@ -1 +0,0 @@ -export { ShellTool } from "../bash" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 05efe69b6359..e525a25bcd62 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -8,7 +8,6 @@ import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" import { Effect } from "effect" -import { ShellToolID } from "./shell/id" export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -40,7 +39,7 @@ export const TaskTool = Tool.define( const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() - const primaryTools = (cfg.experimental?.primary_tools ?? []).map((item) => ShellToolID.normalize(item)) + const primaryTools = cfg.experimental?.primary_tools ?? [] if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index b5825ab36f23..3d0112ba514f 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -607,12 +607,12 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -755,7 +755,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "error", input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index defce40c14f3..014f9ed2949a 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -414,13 +414,13 @@ describe("session-entry-stepper", () => { (callID, title, input, output, metadata, attachments, parts) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", 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", + tool: "shell", input, provider: { executed: true }, timestamp: time(parts.length + 2), @@ -459,10 +459,10 @@ describe("session-entry-stepper", () => { 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.Input.Started.create({ callID, name: "shell", timestamp: time(1) }), SessionEvent.Tool.Called.create({ callID, - tool: "bash", + tool: "shell", input, provider: { executed: true }, timestamp: time(2), @@ -496,7 +496,7 @@ describe("session-entry-stepper", () => { FastCheck.property(word, word, (callID, title) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }), SessionEvent.Tool.Success.create({ callID, title, @@ -691,10 +691,10 @@ describe("session-entry-stepper", () => { 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.Input.Started.create({ callID, name: "shell", timestamp: time(reason.length + 4) }), SessionEvent.Tool.Called.create({ callID, - tool: "bash", + tool: "shell", input, provider: { executed: true }, timestamp: time(reason.length + 5), @@ -771,10 +771,10 @@ describe("session-entry-stepper", () => { 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.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }), SessionEvent.Tool.Called.create({ callID: "a", - tool: "bash", + tool: "shell", input: a, provider: { executed: true }, timestamp: time(2), @@ -789,7 +789,7 @@ describe("session-entry-stepper", () => { SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), SessionEvent.Tool.Called.create({ callID: "b", - tool: "bash", + tool: "shell", input: b, provider: { executed: true }, timestamp: time(5), @@ -827,13 +827,13 @@ describe("session-entry-stepper", () => { 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: "a", name: "shell", 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", + tool: "shell", input: a, provider: { executed: true }, timestamp: time(5), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b14cad7e0150..b06bee2ddcec 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" import { ShellToolID } from "../../src/tool/shell/id" -import { ShellTool } from "../../src/tool/shell/tool" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 06f28bde2589..faf73d9edf23 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -378,7 +378,7 @@ describe("tool.task", () => { }, }, experimental: { - primary_tools: ["bash", "read"], + primary_tools: ["shell", "read"], }, }, }, diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 1ee7d3c360c2..3b0b293319e7 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -35,7 +35,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", shell: "ui.tool.shell", - bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } From 6d66973fd5f56bd58887cf610771ea84227707b2 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:39:19 +1000 Subject: [PATCH 41/84] clean --- packages/opencode/src/acp/agent.ts | 8 +- packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- packages/opencode/src/tool/shell.ts | 263 ++++++++++++++---- packages/opencode/src/tool/shell/shell.txt | 62 +---- packages/opencode/src/tool/task.ts | 3 +- packages/opencode/test/tool/task.test.ts | 2 +- packages/ui/src/components/message-part.tsx | 2 +- packages/web/src/components/share/part.tsx | 2 +- 10 files changed, 232 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 342ee3955f12..615db6c7175a 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -291,7 +291,7 @@ export class Agent implements ACPAgent { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (ShellToolID.has(part.tool)) { + if (ShellToolID.normalize(part.tool) === ShellToolID.id) { if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1106,7 +1106,7 @@ export class Agent implements ACPAgent { } private shellOutput(part: ToolPart) { - if (!ShellToolID.has(part.tool)) return + if (ShellToolID.normalize(part.tool) !== ShellToolID.id) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,7 +1549,7 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() - if (ShellToolID.has(tool)) return "execute" + if (ShellToolID.normalize(tool) === ShellToolID.id) return "execute" switch (tool) { case "webfetch": @@ -1576,7 +1576,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() - if (ShellToolID.has(tool)) return [] + if (ShellToolID.normalize(tool) === ShellToolID.id) return [] switch (tool) { case "read": diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 06cfda542dfd..ca5ba6ccf0e8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -409,7 +409,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (ShellToolID.has(part.tool)) return shell(props(part)) + if (ShellToolID.normalize(part.tool) === ShellToolID.id) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "read") return read(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 31f356137349..bf22654dbbaf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1541,7 +1541,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 0dbca2199ee8..2581be10ee84 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -288,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (ShellToolID.has(permission)) { + if (ShellToolID.normalize(permission) === ShellToolID.id) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 952362995907..97e447df6a8b 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -51,21 +51,201 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const Parameters = z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), -}) +const describe = { + bash: + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', +} + +const Parameters = (description: string) => + z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z.string().describe(description), + }) + +type Parameters = z.infer> + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${Truncate.MAX_LINES} lines or ${Truncate.MAX_BYTES} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${Truncate.MAX_LINES} lines or ${Truncate.MAX_BYTES} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function promptProfile(name: string, platform: NodeJS.Platform) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/"), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: describe.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain), + gitCommands: "bash commands", + toolName: "Bash", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: describe.bash, + } +} type Part = { type: string @@ -573,46 +753,25 @@ export const ShellTool = Tool.define( Effect.sync(() => { const shell = Shell.acceptable() const name = Shell.name(shell) - const shellName = name === "pwsh" ? "PowerShell Core" : name === "powershell" ? "Windows PowerShell" : name - const listCmd = name === "cmd" ? "dir" : PS.has(name) ? "Get-ChildItem" : "ls" - const guidance = - name === "pwsh" - ? `# PowerShell 7+ (pwsh) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - : name === "powershell" - ? `# Windows PowerShell 5.1 shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - : "" - const chain = - name === "powershell" - ? "use shell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`)." + const profile = promptProfile(name, process.platform) + const description = renderPrompt(DESCRIPTION, { + intro: profile.intro, + os: process.platform, + shell: name, + workdirSection: profile.workdirSection, + commandSection: profile.commandSection, + gitCommands: profile.gitCommands, + toolName: profile.toolName, + gitCommandRestriction: profile.gitCommandRestriction, + createPrInstruction: profile.createPrInstruction, + createPrExample: profile.createPrExample, + }) log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${shellName}", shellName) - .replaceAll("${guidance}", guidance) - .replaceAll("${listCmd}", listCmd) - .replaceAll("${toolName}", "Shell") - .replaceAll("${gitCmds}", "git commands") - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + description, + parameters: Parameters(profile.parameterDescription), + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 99d12f6dd811..1b137c46b52c 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,12 @@ -Executes a given ${shellName} command with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the \`workdir\` parameter if you need to run a command in a different directory. AVOID using \`cd && \` patterns - use \`workdir\` instead. +${workdirSection} IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -${guidance} - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`${listCmd}\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`${listCmd} foo\` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using ${toolName} with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +25,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCmds} in parallel, each using the ${toolName} tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -84,7 +42,7 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides ${gitCmds} +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. @@ -95,20 +53,18 @@ Use the gh command via the ${toolName} tool for ALL GitHub-related tasks includi IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCmds} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote - - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) + - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e525a25bcd62..b469fb376ebd 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,5 +1,6 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" +import { ShellToolID } from "./shell/id" import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" @@ -39,7 +40,7 @@ export const TaskTool = Tool.define( const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() - const primaryTools = cfg.experimental?.primary_tools ?? [] + const primaryTools = (cfg.experimental?.primary_tools ?? []).map(ShellToolID.normalize) if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index faf73d9edf23..06f28bde2589 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -378,7 +378,7 @@ describe("tool.task", () => { }, }, experimental: { - primary_tools: ["shell", "read"], + primary_tools: ["bash", "read"], }, }, }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3ea80677ebfe..34fea63c26c4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -271,7 +271,7 @@ export type ToolInfo = { subtitle?: string } -const SHELL = new Set(["shell"]) +const SHELL = new Set(["shell", "bash"]) function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 974ccef43f49..543a5b885d95 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,7 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 -const SHELL = new Set(["shell"]) +const SHELL = new Set(["shell", "bash"]) export interface PartProps { index: number From 0d500a735f87178a0058486a8b7ca83eb0f25ba0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:44:06 +1000 Subject: [PATCH 42/84] Create todo.spec.ts --- packages/app/e2e/todo.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/app/e2e/todo.spec.ts diff --git a/packages/app/e2e/todo.spec.ts b/packages/app/e2e/todo.spec.ts new file mode 100644 index 000000000000..dac2d8ee824f --- /dev/null +++ b/packages/app/e2e/todo.spec.ts @@ -0,0 +1,11 @@ +import { test } from "@playwright/test" + +test( + "test something cool", + { + annotation: { type: "todo" }, + }, + async () => { + test.fixme() + }, +) From cffb8eb1e303bd4636b8e39cf2f84b317dac4b50 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:54:08 +1000 Subject: [PATCH 43/84] . --- packages/opencode/src/cli/cmd/agent.ts | 2 ++ packages/ui/src/components/message-part.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index dee5fea7ac9a..e49f09e62ab1 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,6 +10,7 @@ import fs from "fs/promises" import { Filesystem } from "../../util" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { ShellToolID } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" @@ -128,6 +129,7 @@ const AgentCreateCommand = cmd({ cliTools .split(",") .map((t) => t.trim()) + .map(ShellToolID.normalize) .filter(Boolean), ), ] diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 34fea63c26c4..429b0abd9e0b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1258,6 +1258,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { + if (name === "bash") return state.shell?.render return state[name]?.render } From 26d77add77e52214bb611504ddab8a977729cca3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:03:16 +1000 Subject: [PATCH 44/84] edges --- packages/opencode/src/session/llm.ts | 8 +- packages/opencode/src/tool/shell.ts | 7 +- packages/opencode/test/session/llm.test.ts | 94 ++++++++++++++++++++++ packages/opencode/test/tool/shell.test.ts | 32 ++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 848d06c888c9..bfc84df1ba37 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,6 +22,7 @@ import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" +import { ShellToolID } from "@/tool/shell/id" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -453,7 +454,12 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter(input.tools, (_, k) => { + const userTool = input.user.tools?.[k] + if (userTool !== undefined) return userTool !== false && !disabled.has(k) + if (k === ShellToolID.id && input.user.tools?.[ShellToolID.legacy] === false) return false + return !disabled.has(k) + }) } // Check if messages contain any tool-call content diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 97e447df6a8b..8dc7d7ccb840 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -13,14 +13,14 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" -import { ShellToolID } from "./shell/id" +import { ShellKind, ShellToolID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ShellArity } from "./shell/arity" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -551,6 +551,7 @@ export const ShellTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellKind.from(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) @@ -569,7 +570,7 @@ export const ShellTool = Tool.define( if (tokens.length && (!cmd || !CWD.has(cmd))) { scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *") } } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 2b1df9213165..663bfe3218a2 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -561,6 +561,100 @@ describe("session.llm.stream", () => { }) }) + test("disables shell when user message uses legacy bash override", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-legacy-bash-tools") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-legacy-bash-tools"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + tools: { bash: false }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + shell: tool({ + description: "Run a shell command", + inputSchema: z.object({ command: z.string() }), + execute: async () => ({ output: "" }), + }), + read: tool({ + description: "Read a file", + inputSchema: z.object({ filePath: z.string() }), + execute: async () => ({ output: "" }), + }), + }, + }) + + const capture = await request + const names = + (capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) => + item.function?.name ? [item.function.name] : [], + ) ?? [] + + expect(names).not.toContain("shell") + expect(names).toContain("read") + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b06bee2ddcec..6f1366ff5348 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -237,6 +237,38 @@ describe("tool.shell permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === expectedPermission) + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { await Instance.provide({ directory: projectRoot, From 4f8ff6ab53aa7515ad3fe8eee6eb978e58dbe407 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:23:18 +1000 Subject: [PATCH 45/84] . --- packages/opencode/src/session/llm.ts | 12 +++++++----- packages/opencode/test/session/llm.test.ts | 13 ++++++++++++- packages/ui/src/components/tool-error-card.tsx | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index bfc84df1ba37..a693f47fd028 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -207,11 +207,7 @@ const live: Layer.Layer< input.model.providerID.toLowerCase().includes("litellm") || input.model.api.id.toLowerCase().includes("litellm") - const repair = (toolName: string) => { - const next = toolName.toLowerCase() - if (!tools[next]) return - return next - } + const repair = (toolName: string) => repairToolName(toolName, tools) // LiteLLM/Bedrock rejects requests where the message history contains tool // calls but no tools param is present. When there are no active tools (e.g. @@ -449,6 +445,12 @@ export const defaultLayer = Layer.suspend(() => ), ) +export function repairToolName(toolName: string, tools: Record) { + const next = ShellToolID.normalize(toolName.toLowerCase()) + if (!tools[next]) return + return next +} + function resolveTools(input: Pick) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 663bfe3218a2..0ad0b2b695d1 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import { tool, type ModelMessage } from "ai" +import { tool, type ModelMessage, type Tool } from "ai" import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" @@ -119,6 +119,17 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.repairToolName", () => { + test("normalizes legacy bash alias to shell when available", () => { + expect(LLM.repairToolName("bash", { shell: {} as Tool })).toBe("shell") + expect(LLM.repairToolName("BASH", { shell: {} as Tool })).toBe("shell") + }) + + test("returns undefined when normalized tool is unavailable", () => { + expect(LLM.repairToolName("bash", { read: {} as Tool })).toBeUndefined() + }) +}) + type Capture = { url: URL headers: Headers diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 3b0b293319e7..7d22c9ff74f9 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -34,6 +34,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", + bash: "ui.tool.shell", shell: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", From acf3b00790d1be4da61fea0d6323fb41eec70764 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Apr 2026 13:49:37 +0800 Subject: [PATCH 46/84] feat(app): configure TanStack Query client with default options Add defaultOptions to QueryClient to disable automatic refetching: - refetchOnReconnect: false - refetchOnMount: false - refetchOnWindowFocus: false --- packages/app/src/app.tsx | 10 +- .../app/src/components/dialog-select-mcp.tsx | 2 +- .../src/components/status-popover-body.tsx | 42 ---- packages/app/src/context/global-sync.tsx | 192 +++++++++------- .../app/src/context/global-sync/bootstrap.ts | 209 +++++++++--------- .../src/context/global-sync/child-store.ts | 27 ++- 6 files changed, 251 insertions(+), 231 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 18c6fef30a9e..bf8138fcdeae 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -82,7 +82,15 @@ declare global { } function QueryProvider(props: ParentProps) { - const client = new QueryClient() + const client = new QueryClient({ + defaultOptions: { + queries: { + refetchOnReconnect: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, + }) return {props.children} } diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 98f262ce5a32..0f5aebc6d158 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -47,7 +47,7 @@ export const DialogSelectMcp: Component = () => { .status() .then((result) => { sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) + // sync.set("mcp_ready", true) setState("done", true) }) .catch((err) => { diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0f6a1c1355f0..f2cdd1a6a427 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -162,14 +162,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() - const sdk = useSDK() - - const [load, setLoad] = createStore({ - lspDone: false, - lspLoading: false, - mcpDone: false, - mcpLoading: false, - }) const fail = (err: unknown) => { showToast({ @@ -181,40 +173,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { createEffect(() => { if (!props.shown()) return - - if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) { - setLoad("mcpLoading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) - }) - .catch((err) => { - setLoad("mcpDone", true) - fail(err) - }) - .finally(() => { - setLoad("mcpLoading", false) - }) - } - - if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) { - setLoad("lspLoading", true) - void sdk.client.lsp - .status() - .then((result) => { - sync.set("lsp", result.data ?? []) - sync.set("lsp_ready", true) - }) - .catch((err) => { - setLoad("lspDone", true) - fail(err) - }) - .finally(() => { - setLoad("lspLoading", false) - }) - } }) let dialogRun = 0 diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b742667d72c1..6f6de45c0794 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -10,21 +10,30 @@ import type { import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" +import { createStore, produce, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" +import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" -import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" +import { + bootstrapDirectory, + bootstrapGlobal, + clearProviderRev, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, +} from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" -import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" +import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" -import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" +import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" type GlobalStore = { ready: boolean @@ -43,6 +52,18 @@ type GlobalStore = { export const loadSessionsQuery = (directory: string) => queryOptions({ queryKey: [directory, "loadSessions"], queryFn: skipToken }) +export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "mcp"], + queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken, + }) + +export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "lsp"], + queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? {}) : skipToken, + }) + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -54,30 +75,68 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const [projectCache, setProjectCache, projectInit] = persisted( + Persist.global("globalSync.project", ["globalSync.project.v1"]), + createStore({ value: [] as Project[] }), + ) + + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ + queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], + })) + const [globalStore, setGlobalStore] = createStore({ - ready: false, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - project: [], + get ready() { + return bootstrap.isPending + }, + project: projectCache.value, session_todo: {}, - provider: { all: [], connected: [], default: {} }, provider_auth: {}, - config: {}, - reload: undefined, + get path() { + const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } + if (pathQuery.isLoading) return EMPTY + return pathQuery.data ?? EMPTY + }, + get provider() { + const EMPTY = { all: [], connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + return providerQuery.data ?? EMPTY + }, + get config() { + if (configQuery.isLoading) return {} + return configQuery.data ?? {} + }, + get reload() { + return updateConfigMutation.isPending ? "pending" : undefined + }, }) const queryClient = useQueryClient() + let active = true + let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined + onCleanup(() => { + active = false + }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) + const cacheProjects = () => { + setProjectCache( + "value", + untrack(() => globalStore.project.map(sanitizeProject)), + ) + } + const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { + projectWritten = true setGlobalStore("project", next) + cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -88,6 +147,22 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore + const bootstrap = useQuery(() => ({ + queryKey: ["bootstrap"], + queryFn: async () => { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + queryClient, + }) + bootedAt = Date.now() + return bootedAt + }, + })) + const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) @@ -96,6 +171,16 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore + if (projectInit instanceof Promise) { + void projectInit.then(() => { + if (!active) return + if (projectWritten) return + const cached = projectCache.value + if (cached.length === 0) return + setGlobalStore("project", cached) + }) + } + const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -112,11 +197,11 @@ function createGlobalSync() { const paused = () => untrack(() => globalStore.reload) !== undefined - const queue = createRefreshQueue({ - paused, - bootstrap, - bootstrapInstance, - }) + // const queue = createRefreshQueue({ + // paused, + // bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), + // bootstrapInstance, + // }) const children = createChildStoreManager({ owner, @@ -126,7 +211,7 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - queue.clear(directory) + // queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) clearProviderRev(directory) @@ -264,33 +349,20 @@ function createGlobalSync() { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - if (event.type === "session.error") { - const error = event.properties.error - if (error?.name !== "MessageAbortedError") { - console.error("[global-sync] session error", { - scope: directory === "global" ? "global" : "workspace", - directory: directory === "global" ? undefined : directory, - project: directory === "global" ? undefined : getFilename(directory), - sessionID: event.properties.sessionID, - error, - }) - } - } - if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, refresh: () => { if (recent) return - queue.refresh() + bootstrap.refetch() }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { - queue.push(directory) + // queue.push(directory) } } return @@ -305,47 +377,27 @@ function createGlobalSync() { directory, store, setStore, - push: queue.push, + push: () => {}, // queue.push, setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - void sdkFor(directory) - .lsp.status() - .then((x) => { - setStore("lsp", x.data ?? []) - setStore("lsp_ready", true) - }) + void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))).then((data) => { + setStore("lsp", data ?? []) + }) }, }) }) onCleanup(unsub) - onCleanup(() => { - queue.dispose() - }) + // onCleanup(() => { + // queue.dispose() + // }) onCleanup(() => { for (const directory of Object.keys(children.children)) { children.disposeDirectory(directory) } }) - async function bootstrap() { - bootingRoot = true - try { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - queryClient, - }) - bootedAt = Date.now() - } finally { - bootingRoot = false - } - } - onMount(() => { if (typeof requestAnimationFrame === "function") { eventFrame = requestAnimationFrame(() => { @@ -361,7 +413,6 @@ function createGlobalSync() { void globalSDK.event.start() }, 0) } - void bootstrap() }) const projectApi = { @@ -374,21 +425,10 @@ function createGlobalSync() { }, } - const updateConfig = async (config: Config) => { - setGlobalStore("reload", "pending") - return globalSDK.client.global.config - .update({ config }) - .then(bootstrap) - .then(() => { - queue.refresh() - setGlobalStore("reload", undefined) - queue.refresh() - }) - .catch((error) => { - setGlobalStore("reload", undefined) - throw error - }) - } + const updateConfigMutation = useMutation(() => ({ + mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), + // onSuccess: () => bootstrap.refetch(), + })) return { data: globalStore, @@ -401,8 +441,8 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, - bootstrap, - updateConfig, + // bootstrap, + updateConfig: updateConfigMutation.mutateAsync, project: projectApi, todo: { set: setSessionTodo, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index be789a5e53a6..68895baeca6d 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,6 +19,7 @@ import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" +import { loadMcpQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -66,6 +67,62 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + +export const loadGlobalConfigQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: ["config"], + queryFn: sdk + ? () => + retry(() => + sdk.global.config.get().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, + }) + +export const loadProjectsQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>["data"]) => void, +) => + queryOptions({ + queryKey: ["project"], + queryFn: sdk + ? () => + retry(() => + sdk.project + .list() + .then((x) => { + return (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + }) + .then(transform), + ) + : skipToken, + }) + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -74,61 +131,21 @@ export async function bootstrapGlobal(input: { setGlobalStore: SetStoreFunction queryClient: QueryClient }) { - const fast = [ - () => - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - ] - const slow = [ + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)), + () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)), + () => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)), () => - input.queryClient.fetchQuery({ - ...loadProvidersQuery(null), - queryFn: () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - return null - }), - ), - }), - () => - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), + input.queryClient.fetchQuery( + loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])), ), ] - await runAll(fast) - // showErrors({ - // errors: errors(await runAll(fast)), - // title: input.requestFailedTitle, - // translate: input.translate, - // formatMoreCount: input.formatMoreCount, - // }) - await waitForPaint() - await runAll(slow) - // showErrors({ - // errors: errors(), - // title: input.requestFailedTitle, - // translate: input.translate, - // formatMoreCount: input.formatMoreCount, - // }) - input.setGlobalStore("ready", true) + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) } function groupBySession(input: T[]) { @@ -179,26 +196,28 @@ function warmSessions(input: { ).then(() => undefined) } -export const loadProvidersQuery = (directory: string | null) => - queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) +export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "providers"], + queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken, + }) export const loadAgentsQuery = ( directory: string | null, sdk?: OpencodeClient, transform?: (x: Awaited>) => void, ) => - queryOptions({ + queryOptions({ queryKey: [directory, "agents"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.app - .agents() - .then(transform) - .then(() => null), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.app.agents().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export const loadPathQuery = ( @@ -208,16 +227,15 @@ export const loadPathQuery = ( ) => queryOptions({ queryKey: [directory, "path"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.path.get().then(async (x) => { - transform(x) - return x.data! - }), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.path.get().then(async (x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export async function bootstrapDirectory(input: { @@ -247,12 +265,7 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", input.global.config) } - if (loading || input.store.provider.all.length === 0) { - input.setStore("provider_ready", false) - } - input.setStore("mcp_ready", false) input.setStore("mcp", {}) - input.setStore("lsp_ready", false) input.setStore("lsp", []) if (loading) input.setStore("status", "partial") @@ -339,34 +352,20 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), + () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), + input.queryClient.ensureQueryData( + loadProvidersQuery(input.directory, input.sdk), + // .catch((err) => { + // if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) + // const project = getFilename(input.directory) + // showToast({ + // variant: "error", + // title: input.translate("toast.project.reloadFailed.title", { project }), + // description: formatServerError(err, input.translate), + // }) + // }) ), - () => - input.queryClient.ensureQueryData({ - ...loadProvidersQuery(input.directory), - queryFn: () => - retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }) - .then(() => null), - }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index f3b613a7f248..10704f35ab79 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,8 +14,9 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" -import { useQuery } from "@tanstack/solid-query" -import { loadPathQuery } from "./bootstrap" +import { useQueries } from "@tanstack/solid-query" +import { loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { loadLspQuery, loadMcpQuery } from "../global-sync" export function createChildStoreManager(input: { owner: Owner @@ -158,12 +159,22 @@ export function createChildStoreManager(input: { createRoot((dispose) => { const initialIcon = icon[0].value - const pathQuery = useQuery(() => loadPathQuery(directory)) + const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ + queries: [ + loadPathQuery(directory), + loadMcpQuery(directory), + loadLspQuery(directory), + loadProvidersQuery(directory), + ], + })) + const child = createStore({ project: "", projectMeta: undefined, icon: initialIcon, - provider_ready: false, + get provider_ready() { + return providerQuery.isLoading + }, provider: { all: [], connected: [], default: {} }, config: {}, get path() { @@ -181,9 +192,13 @@ export function createChildStoreManager(input: { todo: {}, permission: {}, question: {}, - mcp_ready: false, + get mcp_ready() { + return mcpQuery.isLoading + }, mcp: {}, - lsp_ready: false, + get lsp_ready() { + return lspQuery.isLoading + }, lsp: [], vcs: vcsStore.value, limit: 5, From e8f56bace153d5f033140d1aa3da838f33f1b1ee Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Apr 2026 14:25:27 +0800 Subject: [PATCH 47/84] simplify mcp loading --- .../app/src/components/dialog-select-mcp.tsx | 62 ++----------- .../src/components/status-popover-body.tsx | 7 +- packages/app/src/context/global-sync.tsx | 89 ++++++------------- .../app/src/context/global-sync/bootstrap.ts | 22 ++--- .../context/global-sync/child-store.test.ts | 1 + .../src/context/global-sync/child-store.ts | 29 ++++-- 6 files changed, 70 insertions(+), 140 deletions(-) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 0f5aebc6d158..9bb36d32d838 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,13 +1,12 @@ -import { useMutation } from "@tanstack/solid-query" -import { Component, createEffect, createMemo, on, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { useMutation, useQueryClient } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" -import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +import { loadMcpQuery } from "@/context/global-sync" const statusLabels = { connected: "mcp.status.connected", @@ -20,48 +19,7 @@ export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() - const [state, setState] = createStore({ - done: false, - loading: false, - }) - - createEffect( - on( - () => sync.data.mcp_ready, - (ready, prev) => { - if (!ready && prev) setState("done", false) - }, - { defer: true }, - ), - ) - - createEffect(() => { - if (state.done || state.loading) return - if (sync.data.mcp_ready) { - setState("done", true) - return - } - - setState("loading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - // sync.set("mcp_ready", true) - setState("done", true) - }) - .catch((err) => { - setState("done", true) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) - .finally(() => { - setState("loading", false) - }) - }) + const queryClient = useQueryClient() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -71,16 +29,10 @@ export const DialogSelectMcp: Component = () => { const toggle = useMutation(() => ({ mutationFn: async (name: string) => { - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) + if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) + else await sdk.client.mcp.connect({ name }) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index f2cdd1a6a427..952e3eac64a0 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" -import { useMutation } from "@tanstack/solid-query" +import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" @@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { loadMcpQuery } from "@/context/global-sync" const pollMs = 10_000 @@ -137,14 +138,14 @@ const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() + const queryClient = useQueryClient() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), onError: (err) => { showToast({ variant: "error", diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 6f6de45c0794..136d8cb158e5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -10,9 +10,8 @@ import type { import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" -import { createStore, produce, reconcile, unwrap } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" import { @@ -31,9 +30,9 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" -import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createRefreshQueue } from "./global-sync/queue" type GlobalStore = { ready: boolean @@ -61,7 +60,7 @@ export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) => export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => queryOptions({ queryKey: [directory, "lsp"], - queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? {}) : skipToken, + queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken, }) function createGlobalSync() { @@ -75,11 +74,6 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() - const [projectCache, setProjectCache, projectInit] = persisted( - Persist.global("globalSync.project", ["globalSync.project.v1"]), - createStore({ value: [] as Project[] }), - ) - const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], })) @@ -88,7 +82,7 @@ function createGlobalSync() { get ready() { return bootstrap.isPending }, - project: projectCache.value, + project: [], session_todo: {}, provider_auth: {}, get path() { @@ -111,32 +105,18 @@ function createGlobalSync() { }) const queryClient = useQueryClient() - let active = true - let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined - onCleanup(() => { - active = false - }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const cacheProjects = () => { - setProjectCache( - "value", - untrack(() => globalStore.project.map(sanitizeProject)), - ) - } - const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { - projectWritten = true setGlobalStore("project", next) - cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -171,16 +151,6 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore - if (projectInit instanceof Promise) { - void projectInit.then(() => { - if (!active) return - if (projectWritten) return - const cached = projectCache.value - if (cached.length === 0) return - setGlobalStore("project", cached) - }) - } - const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -197,11 +167,22 @@ function createGlobalSync() { const paused = () => untrack(() => globalStore.reload) !== undefined - // const queue = createRefreshQueue({ - // paused, - // bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), - // bootstrapInstance, - // }) + const queue = createRefreshQueue({ + paused, + bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), + bootstrapInstance, + }) + + const sdkFor = (directory: string) => { + const cached = sdkCache.get(directory) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(directory, sdk) + return sdk + } const children = createChildStoreManager({ owner, @@ -211,26 +192,16 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - // queue.clear(directory) + queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) clearProviderRev(directory) clearSessionPrefetchDirectory(directory) }, translate: language.t, + getSdk: sdkFor, }) - const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(directory, sdk) - return sdk - } - async function loadSessions(directory: string) { const pending = sessionLoads.get(directory) if (pending) return pending @@ -362,7 +333,7 @@ function createGlobalSync() { if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { - // queue.push(directory) + queue.push(directory) } } return @@ -377,21 +348,19 @@ function createGlobalSync() { directory, store, setStore, - push: () => {}, // queue.push, + push: queue.push, setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))).then((data) => { - setStore("lsp", data ?? []) - }) + void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) }, }) }) onCleanup(unsub) - // onCleanup(() => { - // queue.dispose() - // }) + onCleanup(() => { + queue.dispose() + }) onCleanup(() => { for (const directory of Object.keys(children.children)) { children.disposeDirectory(directory) @@ -427,7 +396,7 @@ function createGlobalSync() { const updateConfigMutation = useMutation(() => ({ mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), - // onSuccess: () => bootstrap.refetch(), + onSuccess: () => bootstrap.refetch(), })) return { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 68895baeca6d..aed7d232a878 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -265,8 +265,6 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", input.global.config) } - input.setStore("mcp", {}) - input.setStore("lsp", []) if (loading) input.setStore("status", "partial") const rev = (providerRev.get(input.directory) ?? 0) + 1 @@ -354,18 +352,14 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => - input.queryClient.ensureQueryData( - loadProvidersQuery(input.directory, input.sdk), - // .catch((err) => { - // if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - // const project = getFilename(input.directory) - // showToast({ - // variant: "error", - // title: input.translate("toast.project.reloadFailed.title", { project }), - // description: formatServerError(err, input.translate), - // }) - // }) - ), + input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index eee763f16dee..24b4a465002d 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,6 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, + getSdk: () => null!, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 10704f35ab79..d3b82894a46c 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 { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -25,6 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string + getSdk: (directory: string) => OpencodeClient }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -157,20 +158,23 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { + const sdk = input.getSdk(directory) + + const initialMeta = meta[0].value const initialIcon = icon[0].value const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(directory), - loadMcpQuery(directory), - loadLspQuery(directory), - loadProvidersQuery(directory), + loadPathQuery(directory, sdk), + loadMcpQuery(directory, sdk), + loadLspQuery(directory, sdk), + loadProvidersQuery(directory, sdk), ], })) const child = createStore({ project: "", - projectMeta: undefined, + projectMeta: initialMeta, icon: initialIcon, get provider_ready() { return providerQuery.isLoading @@ -195,11 +199,15 @@ export function createChildStoreManager(input: { get mcp_ready() { return mcpQuery.isLoading }, - mcp: {}, + get mcp() { + return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) + }, get lsp_ready() { return lspQuery.isLoading }, - lsp: [], + get lsp() { + return lspQuery.isLoading ? [] : (lspQuery.data ?? []) + }, vcs: vcsStore.value, limit: 5, message: {}, @@ -222,6 +230,11 @@ export function createChildStoreManager(input: { child[1]("vcs", (value) => value ?? cached) }) + onPersistedInit(meta[2], () => { + if (child[0].projectMeta !== initialMeta) return + child[1]("projectMeta", meta[0].value) + }) + onPersistedInit(icon[2], () => { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) From 1e0246cdc81c58d6ef533e928b047ea604f47eaf Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:29:19 +0530 Subject: [PATCH 48/84] feat(scout): add repo research tools --- packages/opencode/src/acp/agent.ts | 6 + packages/opencode/src/agent/agent.ts | 39 ++- packages/opencode/src/agent/prompt/scout.txt | 36 +++ packages/opencode/src/cli/cmd/github.ts | 14 +- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/config/permission.ts | 2 + packages/opencode/src/global/index.ts | 2 + packages/opencode/src/tool/registry.ts | 11 + packages/opencode/src/tool/repo_clone.ts | 142 +++++++++++ packages/opencode/src/tool/repo_clone.txt | 5 + packages/opencode/src/tool/repo_overview.ts | 238 ++++++++++++++++++ packages/opencode/src/tool/repo_overview.txt | 4 + packages/opencode/src/util/github-remote.ts | 34 +++ packages/opencode/src/util/repository.ts | 97 +++++++ packages/opencode/test/agent/agent.test.ts | 26 ++ .../opencode/test/cli/github-remote.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + .../opencode/test/tool/repo_clone.test.ts | 198 +++++++++++++++ .../opencode/test/tool/repo_overview.test.ts | 151 +++++++++++ 20 files changed, 1004 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/src/agent/prompt/scout.txt create mode 100644 packages/opencode/src/tool/repo_clone.ts create mode 100644 packages/opencode/src/tool/repo_clone.txt create mode 100644 packages/opencode/src/tool/repo_overview.ts create mode 100644 packages/opencode/src/tool/repo_overview.txt create mode 100644 packages/opencode/src/util/github-remote.ts create mode 100644 packages/opencode/src/util/repository.ts create mode 100644 packages/opencode/test/tool/repo_clone.test.ts create mode 100644 packages/opencode/test/tool/repo_overview.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 672b93f6ceb7..6449e1b02ccd 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1561,6 +1561,8 @@ function toToolKind(toolName: string): ToolKind { case "grep": case "glob": + case "repo_clone": + case "repo_overview": case "context7_resolve_library_id": case "context7_get_library_docs": return "search" @@ -1583,6 +1585,10 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] + case "repo_clone": + return input["path"] ? [{ path: input["path"] }] : [] + case "repo_overview": + return input["path"] ? [{ path: input["path"] }] : [] case "bash": return [] default: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 355718b6bf39..60e9c72ee299 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -11,6 +11,7 @@ import { ProviderTransform } from "../provider" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SCOUT from "./prompt/scout.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" @@ -83,6 +84,10 @@ export const layer = Layer.effect( const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] + const readonlyExternalDirectory = { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + } satisfies Record const defaults = Permission.fromConfig({ "*": "allow", @@ -94,6 +99,8 @@ export const layer = Layer.effect( question: "deny", plan_enter: "deny", plan_exit: "deny", + repo_clone: "deny", + repo_overview: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -172,10 +179,7 @@ export const layer = Layer.effect( websearch: "allow", codesearch: "allow", read: "allow", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, + external_directory: readonlyExternalDirectory, }), user, ), @@ -185,6 +189,33 @@ export const layer = Layer.effect( mode: "subagent", native: true, }, + scout: { + name: "scout", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + repo_clone: "allow", + repo_overview: "allow", + external_directory: { + ...readonlyExternalDirectory, + [path.join(Global.Path.repos, "*")]: "allow", + }, + }), + user, + ), + description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`, + prompt: PROMPT_SCOUT, + options: {}, + mode: "subagent", + native: true, + }, compaction: { name: "compaction", mode: "primary", diff --git a/packages/opencode/src/agent/prompt/scout.txt b/packages/opencode/src/agent/prompt/scout.txt new file mode 100644 index 000000000000..c315cc5a6b26 --- /dev/null +++ b/packages/opencode/src/agent/prompt/scout.txt @@ -0,0 +1,36 @@ +You are `scout`, a read-only research agent for external libraries, dependency source, and documentation. + +Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace. + +Use this agent when asked to: +- inspect dependency repositories or library source +- compare local code against upstream implementations +- research public GitHub repositories the environment can clone +- explain how a library or framework works by reading its source and docs +- investigate third-party APIs, workflows, or behavior outside the current workspace + +Working style: +1. When the task involves a GitHub repository or dependency source, use `repo_clone` first. +2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository. +3. Use `WebFetch` for official documentation pages when source alone is not enough. +4. Prefer direct code and documentation evidence over assumptions. +5. If multiple external repositories are relevant, inspect each one before drawing conclusions. + +Research standards: +- cite exact absolute file paths and line references whenever possible +- separate what is verified from what is inferred +- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise +- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available +- call out uncertainty clearly instead of smoothing over gaps + +Output expectations: +- start with the direct answer +- then explain the evidence repository by repository or source by source +- include file references when relevant +- keep the explanation organized and easy to scan + +Constraints: +- do not modify files or run tools that change the user's workspace +- return absolute file paths for cloned-repo findings in your final response + +Complete the user's research request efficiently and report your findings clearly. diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index fe8e233dd176..c44b58d6a44e 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util" +import { parseGitHubRemote } from "@/util/github-remote" import { Effect } from "effect" type GitHubAuthor = { @@ -152,18 +153,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const type UserEvent = (typeof USER_EVENTS)[number] type RepoEvent = (typeof REPO_EVENTS)[number] -// Parses GitHub remote URLs in various formats: -// - https://github.com/owner/repo.git -// - https://github.com/owner/repo -// - git@github.com:owner/repo.git -// - git@github.com:owner/repo -// - ssh://git@github.com/owner/repo.git -// - ssh://git@github.com/owner/repo -export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} +export { parseGitHubRemote } /** * Extracts displayable text from assistant response parts. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5423ba3baf5f..032007aa7183 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -170,6 +170,7 @@ export const Info = Schema.Struct({ // subagent general: Schema.optional(AgentRef), explore: Schema.optional(AgentRef), + scout: Schema.optional(AgentRef), // specialized title: Schema.optional(AgentRef), summary: Schema.optional(AgentRef), diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index fdd574683705..73b21cbc53c4 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -44,6 +44,8 @@ const InputObject = Schema.StructWithRest( webfetch: Schema.optional(Action), websearch: Schema.optional(Action), codesearch: Schema.optional(Action), + repo_clone: Schema.optional(Rule), + repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 27bac598fb75..998d047fd341 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -20,6 +20,7 @@ export const Path = { data, bin: path.join(cache, "bin"), log: path.join(data, "log"), + repos: path.join(data, "repos"), cache, config, state, @@ -34,6 +35,7 @@ await Promise.all([ fs.mkdir(Path.state, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), + fs.mkdir(Path.repos, { recursive: true }), ]) const CACHE_VERSION = "21" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 0211e33bcbfa..2dfea58f2da8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,6 +21,8 @@ import { Provider } from "../provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" +import { RepoCloneTool } from "./repo_clone" +import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@/flag/flag" import { Log } from "@/util" import { LspTool } from "./lsp" @@ -43,6 +45,7 @@ import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" +import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" @@ -78,6 +81,7 @@ export const layer: Layer.Layer< | Skill.Service | Session.Service | Provider.Service + | Git.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -107,6 +111,8 @@ export const layer: Layer.Layer< const websearch = yield* WebSearchTool const bash = yield* BashTool const codesearch = yield* CodeSearchTool + const repoClone = yield* RepoCloneTool + const repoOverview = yield* RepoOverviewTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -189,6 +195,8 @@ export const layer: Layer.Layer< todo: Tool.init(todo), search: Tool.init(websearch), code: Tool.init(codesearch), + repo_clone: Tool.init(repoClone), + repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -212,6 +220,8 @@ export const layer: Layer.Layer< tool.todo, tool.search, tool.code, + tool.repo_clone, + tool.repo_overview, tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), @@ -326,6 +336,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts new file mode 100644 index 000000000000..0b22ae6432e3 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.ts @@ -0,0 +1,142 @@ +import path from "path" +import z from "zod" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Flock } from "@opencode-ai/shared/util/flock" +import { Git } from "@/git" +import DESCRIPTION from "./repo_clone.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" + +const parameters = z.object({ + repository: z + .string() + .describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"), + refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"), +}) + +function statusForRepository(input: { reuse: boolean; refresh?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const RepoCloneTool = Tool.define( + "repo_clone", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const reference = parseRepositoryReference(params.repository) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + + const repository = reference.label + const remote = reference.remote + const localPath = repositoryCachePath(reference) + const cloneTarget = parseRepositoryReference(remote) ?? reference + + yield* ctx.ask({ + permission: "repo_clone", + patterns: [repository], + always: [repository], + metadata: { + repository, + remote, + path: localPath, + refresh: Boolean(params.refresh), + }, + }) + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* fs.existsSafe(localPath) + const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const status = statusForRepository({ reuse, refresh: params.refresh }) + + if (status === "cloned") { + const clone = yield* git.run(["clone", "--depth", "100", remote, localPath], { cwd: path.dirname(localPath) }) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + title: repository, + metadata: { + repository, + host: reference.host, + remote, + localPath, + status, + head: headText, + branch, + }, + output: [ + `Repository ready: ${repository}`, + `Status: ${status}`, + `Local path: ${localPath}`, + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ].join("\n"), + } + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/repo_clone.txt b/packages/opencode/src/tool/repo_clone.txt new file mode 100644 index 000000000000..7944015506a3 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.txt @@ -0,0 +1,5 @@ +- Clone or refresh a repository into OpenCode's managed cache under the data directory +- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand +- Returns the cached absolute local path so other tools can explore the cloned source +- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace +- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts new file mode 100644 index 000000000000..650bc352f1f7 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.ts @@ -0,0 +1,238 @@ +import path from "path" +import z from "zod" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Git } from "@/git" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./repo_overview.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" +import { Instance } from "@/project/instance" + +const parameters = z + .object({ + repository: z + .string() + .optional() + .describe("Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand"), + path: z.string().optional().describe("Directory path to inspect instead of a cached repository"), + depth: z.number().int().positive().max(6).optional().describe("Maximum structure depth to include. Defaults to 3."), + }) + .refine((input) => Boolean(input.repository || input.path), { + message: "Either repository or path is required", + }) + +type Metadata = { + path: string + repository?: string + branch?: string + head?: string + package_manager?: string + ecosystems: string[] + dependency_files: string[] + entrypoints: string[] + depth: number + truncated: boolean +} + +const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"]) +const STRUCTURE_LIMIT = 200 +const DEPENDENCY_FILES = [ + "package.json", + "package-lock.json", + "bun.lock", + "bun.lockb", + "pnpm-lock.yaml", + "yarn.lock", + "requirements.txt", + "pyproject.toml", + "go.mod", + "Cargo.toml", + "Gemfile", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "composer.json", +] + +function packageManager(files: Set) { + if (files.has("bun.lock") || files.has("bun.lockb")) return "bun" + if (files.has("pnpm-lock.yaml")) return "pnpm" + if (files.has("yarn.lock")) return "yarn" + if (files.has("package-lock.json")) return "npm" +} + +function ecosystems(files: Set) { + return [ + ...(files.has("package.json") ? ["Node.js"] : []), + ...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []), + ...(files.has("go.mod") ? ["Go"] : []), + ...(files.has("Cargo.toml") ? ["Rust"] : []), + ...(files.has("Gemfile") ? ["Ruby"] : []), + ...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []), + ...(files.has("composer.json") ? ["PHP"] : []), + ] +} + +function commonEntrypoints(files: Set) { + return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file)) +} + +export const RepoOverviewTool = Tool.define( + "repo_overview", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: z.infer) { + if (params.path) { + const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) + return { path: full, repository: params.repository } + } + + const parsed = parseRepositoryReference(params.repository!) + if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + + const repository = parsed.label + return { + repository, + path: repositoryCachePath(parsed), + } + }) + + const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) { + let truncated = false + const lines: string[] = [] + + const visit: (dir: string, level: number) => Effect.Effect = Effect.fnUntraced(function* (dir: string, level: number) { + if (level >= depth || lines.length >= STRUCTURE_LIMIT) { + truncated = truncated || lines.length >= STRUCTURE_LIMIT + return + } + + const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => [])) + const sorted = yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + if (IGNORED_DIRS.has(entry.name)) return undefined + const full = path.join(dir, entry.name) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return undefined + return { name: entry.name, full, directory: info.type === "Directory" } + }), + { concurrency: 16 }, + ).pipe( + Effect.map((items) => + items + .filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item)) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + ), + ) + + for (const entry of sorted) { + if (lines.length >= STRUCTURE_LIMIT) { + truncated = true + return + } + + lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`) + if (entry.directory) yield* visit(entry.full, level + 1) + } + }) + + yield* visit(root, 0) + return { lines, truncated } + }) + + return { + description: DESCRIPTION, + parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const target = yield* resolveTarget(params) + const depth = params.depth ?? 3 + + yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) + yield* ctx.ask({ + permission: "repo_overview", + patterns: [target.repository ?? target.path], + always: [target.repository ?? target.path], + metadata: { + repository: target.repository, + path: target.path, + depth, + }, + }) + + const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) { + if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) + throw new Error(`Directory not found: ${target.path}`) + } + if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`) + + const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => [])) + const topLevel = new Set(entries.map((entry) => entry.name)) + const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file)) + const packageJson = topLevel.has("package.json") + ? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record + : {} + + const entrypoints = [ + ...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []), + ...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []), + ...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []), + ...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []), + ...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin) + ? Object.keys(packageJson.bin as Record).map((name) => `bin: ${name}`) + : []), + ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) + ? Object.keys(packageJson.exports as Record).slice(0, 10).map((name) => `exports: ${name}`) + : []), + ] + + const common = commonEntrypoints(new Set([ + ...topLevel, + ...entries + .filter((entry) => entry.name === "src") + .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), + ])) + const structureResult = yield* structure(target.path, depth) + const branch = yield* git.branch(target.path) + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path }) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + const metadata: Metadata = { + path: target.path, + repository: target.repository, + branch, + head: headText, + package_manager: packageManager(topLevel), + ecosystems: ecosystems(topLevel), + dependency_files: dependencyFiles, + entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)], + depth, + truncated: structureResult.truncated, + } + + return { + title: target.repository ?? path.basename(target.path), + metadata, + output: [ + `Path: ${target.path}`, + ...(target.repository ? [`Repository: ${target.repository}`] : []), + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []), + ...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []), + ...(metadata.dependency_files.length ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] : []), + ...(metadata.entrypoints.length ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] : []), + "Top-level structure:", + ...structureResult.lines, + ...(structureResult.truncated ? ["(Structure truncated)"] : []), + ].join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/repo_overview.txt b/packages/opencode/src/tool/repo_overview.txt new file mode 100644 index 000000000000..210983874655 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.txt @@ -0,0 +1,4 @@ +- Summarize the structure and likely entrypoints of a cloned repository or local directory +- Accepts either a cached repository reference or a directory path +- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree +- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation diff --git a/packages/opencode/src/util/github-remote.ts b/packages/opencode/src/util/github-remote.ts new file mode 100644 index 000000000000..fc30e2cfcfde --- /dev/null +++ b/packages/opencode/src/util/github-remote.ts @@ -0,0 +1,34 @@ +function normalize(input: string) { + return input.trim().replace(/^git\+/, "").replace(/#.*$/, "") +} + +export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { + const match = normalize(url).match(/^(?:(?:https?|ssh|git):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) + if (!match) return null + return { owner: match[1], repo: match[2] } +} + +export function parseGitHubRepository(input: string): { owner: string; repo: string } | null { + const cleaned = normalize(input) + const remote = parseGitHubRemote(cleaned) + if (remote) return remote + + const prefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (prefixed) { + return { owner: prefixed[1], repo: prefixed[2].replace(/\.git$/, "") } + } + + const match = cleaned.match(/^([^/\s]+)\/([^/\s]+)$/) + if (!match) return null + return { owner: match[1], repo: match[2].replace(/\.git$/, "") } +} + +export function githubRepositoryURL(input: { owner: string; repo: string }) { + return `https://github.com/${input.owner}/${input.repo}` +} + +export function githubCloneURL(input: { owner: string; repo: string }) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${input.owner}/${input.repo}.git` + return new URL(`${input.owner}/${input.repo}.git`, base.endsWith("/") ? base : `${base}/`).href +} diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 000000000000..f9ffb0e49cd4 --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -0,0 +1,97 @@ +import path from "path" +import { Global } from "@/global" + +export type Reference = { + host: string + path: string + segments: string[] + owner?: string + repo: string + remote: string + label: string +} + +function normalize(input: string) { + return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "") +} + +function trimGitSuffix(input: string) { + return input.replace(/\.git$/, "") +} + +function parts(input: string) { + return input + .split("/") + .map((item) => trimGitSuffix(item.trim())) + .filter(Boolean) +} + +function hostLike(input: string) { + return input.includes(".") || input.includes(":") || input === "localhost" +} + +function withSlash(input: string) { + return input.endsWith("/") ? input : `${input}/` +} + +function githubRemote(pathname: string) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${pathname}.git` + return new URL(`${pathname}.git`, withSlash(base)).href +} + +function build(input: { host: string; segments: string[]; remote?: string }) { + const segments = input.segments.map(trimGitSuffix).filter(Boolean) + if (!segments.length) return null + const pathname = segments.join("/") + const repo = segments[segments.length - 1] + const host = input.host.toLowerCase() + return { + host, + path: pathname, + segments, + owner: segments.length === 2 ? segments[0] : undefined, + repo, + remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), + label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, + } satisfies Reference +} + +export function parseRepositoryReference(input: string) { + const cleaned = normalize(input) + if (!cleaned) return null + + const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + + if (!cleaned.includes("://")) { + const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) + if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + + const direct = parts(cleaned) + if (direct.length >= 2 && hostLike(direct[0])) { + return build({ host: direct[0], segments: direct.slice(1) }) + } + + if (direct.length === 2) { + return build({ host: "github.com", segments: direct }) + } + } + + try { + const url = new URL(cleaned) + const pathname = parts(url.pathname) + const host = url.protocol === "file:" ? "file" : url.host + return build({ host, segments: pathname, remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned }) + } catch { + return null + } +} + +export function repositoryCachePath(input: Reference) { + return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return left.host === right.host && left.path === right.path +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 50a3668f98a8..dee53a921224 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -4,6 +4,7 @@ import path from "path" import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" +import { Global } from "../../src/global" import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern @@ -31,6 +32,7 @@ test("returns default native agents when no config", async () => { expect(names).toContain("plan") expect(names).toContain("general") expect(names).toContain("explore") + expect(names).toContain("scout") expect(names).toContain("compaction") expect(names).toContain("title") expect(names).toContain("summary") @@ -49,6 +51,8 @@ test("build agent has correct default properties", async () => { expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") }, }) }) @@ -97,6 +101,28 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy }) }) +test("scout agent allows repo cloning and repo cache reads", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const scout = await load(tmp.path, (svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") + expect( + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, + ).toBe("allow") + }, + }) +}) + test("general agent denies todo tools", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986ead..ed37b92d4106 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => { expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) }) +test("parses git protocol URLs from package metadata", () => { + expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) +}) + +test("parses npm-style github shorthand", () => { + expect(parseGitHubRemote("github:facebook/react")).toBeNull() +}) + test("parses http URL", () => { expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8ffb20f15419..f0eb23d0f0bf 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -15,6 +15,7 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider" import { Env } from "../../src/env" +import { Git } from "../../src/git" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -175,6 +176,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 651754733909..b1518eb1f09c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" import { Agent as AgentSvc } from "../../src/agent/agent" +import { Git } from "../../src/git" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config" @@ -128,6 +129,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts new file mode 100644 index 000000000000..bcc855843dac --- /dev/null +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { pathToFileURL } from "node:url" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Agent } from "../../src/agent/agent" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool" +import { RepoCloneTool } from "../../src/tool/repo_clone" +import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoCloneToolTest.init")(function* () { + const info = yield* RepoCloneTool + return yield* info.init() +}) + +const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + } + return stdout.trim() + }) +}) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +describe("tool.repo_clone", () => { + it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const cloned = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo" }, ctx), + ) + const cached = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), + ) + + expect(cloned.metadata.status).toBe("cloned") + expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(cached.metadata.status).toBe("cached") + expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") + }), + ), + ) + + it.live("refresh updates an existing cached clone", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) + + const tool = yield* init() + const first = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo" }, ctx), + ) + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + const refreshed = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", refresh: true }, ctx), + ) + + expect(first.metadata.status).toBe("cloned") + expect(refreshed.metadata.status).toBe("refreshed") + expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") + }), + ), + ) + + it.live("rejects invalid repository inputs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const result = yield* tool.execute({ repository: "not-a-repo" }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("git URL") + } + }), + ), + ) + + it.live("clones generic git URLs into the managed cache", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "forge") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const result = yield* tool.execute({ repository: pathToFileURL(remoteRepo).href }, ctx) + + expect(result.metadata.status).toBe("cloned") + expect(result.metadata.host).toBe("file") + expect(result.metadata.localPath.startsWith(path.join(Global.Path.repos, "file"))).toBe(true) + expect(result.metadata.localPath.endsWith(path.join("forge", "repo"))).toBe(true) + expect(yield* fs.readFileString(path.join(result.metadata.localPath, "README.md"))).toBe("v1\n") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts new file mode 100644 index 000000000000..b114659a2270 --- /dev/null +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Agent } from "../../src/agent/agent" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool" +import { RepoOverviewTool } from "../../src/tool/repo_overview" +import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoOverviewToolTest.init")(function* () { + const info = yield* RepoOverviewTool + return yield* info.init() +}) + +describe("tool.repo_overview", () => { + it.live("summarizes a local repository path", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs( + path.join(repo, "package.json"), + JSON.stringify( + { + name: "example-repo", + main: "dist/index.js", + module: "dist/index.mjs", + types: "dist/index.d.ts", + exports: { + ".": "./dist/index.js", + "./server": "./dist/server.js", + }, + bin: { + example: "./bin/example.js", + }, + }, + null, + 2, + ), + ) + yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") + yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") + yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") + + const tool = yield* init() + const result = yield* tool.execute({ path: repo }, ctx) + + expect(result.metadata.path).toBe(repo) + expect(result.metadata.ecosystems).toContain("Node.js") + expect(result.metadata.package_manager).toBe("bun") + expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) + expect(result.metadata.entrypoints).toEqual( + expect.arrayContaining([ + "main: dist/index.js", + "module: dist/index.mjs", + "types: dist/index.d.ts", + "exports: .", + "exports: ./server", + "bin: example", + "file: src/index.ts", + ]), + ) + expect(result.output).toContain("Top-level structure:") + expect(result.output).toContain("src/") + expect(result.output).toContain("README.md") + }), + ), + ) + + it.live("resolves a cached repository from repository shorthand", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") + yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("owner/repo") + expect(result.output).toContain("Repository: owner/repo") + expect(result.output).toContain(`Path: ${cached}`) + }), + ), + ) + + it.live("fails clearly when a repository is not cloned", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") + } + }), + ), + ) + + it.live("resolves cached repositories from host/path references", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("gitlab.com/group/repo") + expect(result.output).toContain("Repository: gitlab.com/group/repo") + }), + ), + ) +}) From 0db04ef69f7bbc5a60ae42b8d0187d6f4878d7f3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:29:31 +0530 Subject: [PATCH 49/84] docs: add scout agent docs --- packages/web/src/content/docs/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ar/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/bs/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/da/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/de/agents.mdx | 8 ++++++++ packages/web/src/content/docs/es/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/fr/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/it/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ja/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ko/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/nb/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/pl/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/pt-br/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ru/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/th/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/tr/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/zh-cn/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/zh-tw/agents.mdx | 12 ++++++++++-- 18 files changed, 178 insertions(+), 34 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aae61..96689d8e8700 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -36,13 +36,13 @@ look at these below. Subagents are specialized assistants that primary agents can invoke for specific tasks. You can also manually invoke them by **@ mentioning** them in your messages. -OpenCode comes with two built-in subagents, **General** and **Explore**. We'll look at this below. +OpenCode comes with three built-in subagents, **General**, **Explore**, and **Scout**. We'll look at this below. --- ## Built-in -OpenCode comes with two built-in primary agents and two built-in subagents. +OpenCode comes with two built-in primary agents and three built-in subagents. --- @@ -84,6 +84,14 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use scout + +_Mode_: `subagent` + +A read-only agent for external docs and dependency research. Use this when you need to clone a dependency repository into OpenCode's managed cache, inspect library source, or cross-reference local code against upstream implementations without modifying your workspace. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ar/agents.mdx b/packages/web/src/content/docs/ar/agents.mdx index 01e13fda896c..af12a67691c4 100644 --- a/packages/web/src/content/docs/ar/agents.mdx +++ b/packages/web/src/content/docs/ar/agents.mdx @@ -35,13 +35,13 @@ description: هيّئ الوكلاء المتخصصين واستخدمهم. الوكلاء الفرعيون هم مساعدين متخصصين يمكن للوكلاء الأساسيين استدعاؤهم لمهام محددة. يمكنك أيضا استدعاؤهم يدويا عبر **الإشارة بـ @** في رسائلك. -يأتي OpenCode مع وكيلين فرعيين مدمجين: **General** و **Explore**. سنلقي نظرة على ذلك أدناه. +يأتي OpenCode مع ثلاثة وكلاء فرعيين مدمجين: **General** و **Explore** و **Scout**. سنلقي نظرة على ذلك أدناه. --- ## المدمجة -يأتي OpenCode مع وكيلين أساسيين مدمجين ووكيلين فرعيين مدمجين. +يأتي OpenCode مع وكيلين أساسيين مدمجين وثلاثة وكلاء فرعيين مدمجين. --- @@ -83,6 +83,14 @@ _الوضع_: `subagent` --- +### استخدام Scout + +_الوضع_: `subagent` + +وكيل للقراءة فقط مخصص للوثائق الخارجية وأبحاث التبعيات. استخدمه عندما تحتاج إلى استنساخ مستودع تبعية داخل ذاكرة التخزين المؤقت المُدارة في OpenCode، أو فحص الشفرة المصدرية لمكتبة، أو إجراء مراجع متقاطعة بين الشفرة المحلية والتنفيذات upstream بدون تعديل مساحة العمل الخاصة بك. + +--- + ### استخدام Compaction _الوضع_: `primary` diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx index 8ff674ae67e6..a2e211b19add 100644 --- a/packages/web/src/content/docs/bs/agents.mdx +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -35,13 +35,13 @@ OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. -OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku. +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. --- ## Ugrađeni -OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta. +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. --- @@ -83,6 +83,14 @@ Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati --- +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + ### Compaction agent _Režim_: `primary` diff --git a/packages/web/src/content/docs/da/agents.mdx b/packages/web/src/content/docs/da/agents.mdx index 6ab2e7c39d6e..058f9eec6e25 100644 --- a/packages/web/src/content/docs/da/agents.mdx +++ b/packages/web/src/content/docs/da/agents.mdx @@ -36,13 +36,13 @@ se på disse nedenfor. Subagenter er specialiserede assistenter, som primære agenter kan påbegynde sig til specifikke opgaver. Du kan også kalde dem manuelt ved at **@ nævne** dem i dine beskeder. -OpenCode leveres med to indbyggede underagenter, **Generelt** og **Udforsk**. Vi vil se på dette nedenfor. +OpenCode leveres med tre indbyggede subagenter, **General**, **Explore** og **Scout**. Vi ser nærmere på dem nedenfor. --- ## Indbyggede -OpenCode leveres med to indbyggede primære agenter og to indbyggede subagenter. +OpenCode leveres med to indbyggede primære agenter og tre indbyggede subagenter. --- @@ -84,6 +84,14 @@ En hurtig, skrivebeskyttet agent til at udforske kodebaser. Kan ikke ændre file --- +### Scout-agenten + +_Tilstand_: `subagent` + +En skrivebeskyttet agent til eksterne docs og research af dependencies. Brug denne, når du har brug for at klone et dependency-repository ind i OpenCode's administrerede cache, inspicere kildekoden i et bibliotek eller krydstjekke lokal kode mod upstream-implementeringer uden at ændre dit workspace. + +--- + ### Compact-agenten _Tilstand_: `primary` diff --git a/packages/web/src/content/docs/de/agents.mdx b/packages/web/src/content/docs/de/agents.mdx index 289b113cf647..6bca53488d69 100644 --- a/packages/web/src/content/docs/de/agents.mdx +++ b/packages/web/src/content/docs/de/agents.mdx @@ -70,6 +70,14 @@ Ein schneller, schreibgeschützter Agent zum Erkunden von Codebasen. Dateien kö --- +### Scout + +_Modus_: `subagent` + +Ein schreibgeschützter Agent für externe Dokumentation und Dependency-Recherche. Verwenden Sie ihn, wenn Sie ein Dependency-Repository in den von OpenCode verwalteten Cache klonen, den Quellcode einer Bibliothek untersuchen oder lokalen Code mit Upstream-Implementierungen abgleichen müssen, ohne Ihren Workspace zu verändern. + +--- + ### Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/es/agents.mdx b/packages/web/src/content/docs/es/agents.mdx index 0b2736ac3732..c98a4eb99e59 100644 --- a/packages/web/src/content/docs/es/agents.mdx +++ b/packages/web/src/content/docs/es/agents.mdx @@ -36,13 +36,13 @@ mira estos a continuación. Los subagentes son asistentes especializados que los agentes principales pueden invocar para tareas específicas. También puedes invocarlos manualmente **@ mencionándolos** en tus mensajes. -OpenCode viene con dos subagentes integrados, **General** y **Explore**. Veremos esto a continuación. +OpenCode viene con tres subagentes integrados, **General**, **Explore** y **Scout**. Veremos esto a continuación. --- ## Integrados -OpenCode viene con dos agentes primarios integrados y dos subagentes integrados. +OpenCode viene con dos agentes primarios integrados y tres subagentes integrados. --- @@ -84,6 +84,14 @@ Un agente rápido y de solo lectura para explorar bases de código. No se pueden --- +### Scout + +_Modo_: `subagent` + +Un agente de solo lectura para investigar documentación externa y dependencias. Úsalo cuando necesites clonar el repositorio de una dependencia en la caché administrada de OpenCode, inspeccionar el código fuente de una librería o contrastar el código local con implementaciones upstream sin modificar tu espacio de trabajo. + +--- + ### Compactación _Modo_: `primary` diff --git a/packages/web/src/content/docs/fr/agents.mdx b/packages/web/src/content/docs/fr/agents.mdx index b18d33539436..a6b323dfc858 100644 --- a/packages/web/src/content/docs/fr/agents.mdx +++ b/packages/web/src/content/docs/fr/agents.mdx @@ -36,13 +36,13 @@ Nous les verrons ci-dessous. Les sous-agents sont des assistants spécialisés que les agents primaires peuvent appeler pour des tâches spécifiques. Vous pouvez également les invoquer manuellement en **@ les mentionnant** dans vos messages. -OpenCode est livré avec deux sous-agents intégrés, **General** et **Explore**. Nous verrons cela ci-dessous. +OpenCode est livré avec trois sous-agents intégrés, **General**, **Explore** et **Scout**. Nous les verrons ci-dessous. --- ## Agents intégrés -OpenCode est livré avec deux agents primaires intégrés et deux sous-agents intégrés. +OpenCode est livré avec deux agents primaires intégrés et trois sous-agents intégrés. --- @@ -84,6 +84,14 @@ Un agent rapide en lecture seule pour explorer les bases de code. Impossible de --- +### Agent Scout + +_Mode_ : `subagent` + +Un agent en lecture seule pour la recherche sur la documentation externe et les dépendances. Utilisez-le lorsque vous devez cloner le dépôt d'une dépendance dans le cache géré d'OpenCode, inspecter le code source d'une bibliothèque ou recouper le code local avec les implémentations upstream sans modifier votre espace de travail. + +--- + ### Agent Compaction _Mode_ : `primary` diff --git a/packages/web/src/content/docs/it/agents.mdx b/packages/web/src/content/docs/it/agents.mdx index 4ecc9fc2a293..70aea575339e 100644 --- a/packages/web/src/content/docs/it/agents.mdx +++ b/packages/web/src/content/docs/it/agents.mdx @@ -35,13 +35,13 @@ OpenCode include due agenti primari integrati: **Build** e **Plan**. Li vediamo I subagenti sono assistenti specializzati che gli agenti primari possono invocare per task specifici. Puoi anche invocarli manualmente **menzionandoli con @** nei tuoi messaggi. -OpenCode include due subagenti integrati: **General** e **Explore**. Li vediamo sotto. +OpenCode include tre subagenti integrati: **General**, **Explore** e **Scout**. Li vediamo sotto. --- ## Integrati -OpenCode include due agenti primari integrati e due subagenti integrati. +OpenCode include due agenti primari integrati e tre subagenti integrati. --- @@ -83,6 +83,14 @@ Un agente rapido in sola lettura per esplorare codebase. Non può modificare fil --- +### Scout + +_Mode_: `subagent` + +Un agente in sola lettura per la ricerca su documentazione esterna e dipendenze. Usalo quando devi clonare il repository di una dipendenza nella cache gestita di OpenCode, ispezionare il codice sorgente di una libreria o confrontare il codice locale con implementazioni upstream senza modificare il tuo workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ja/agents.mdx b/packages/web/src/content/docs/ja/agents.mdx index 879a43b057a9..539d30faf838 100644 --- a/packages/web/src/content/docs/ja/agents.mdx +++ b/packages/web/src/content/docs/ja/agents.mdx @@ -35,13 +35,13 @@ OpenCode には、**Build** と **Plan** という 2 つの組み込みプライ サブエージェントは、プライマリエージェントが特定のタスクのために呼び出すことができる特殊なアシスタントです。メッセージ内で **@ メンション**することで、手動で呼び出すこともできます。 -OpenCode には、**General** と **Explore** という 2 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 +OpenCode には、**General**、**Explore**、**Scout** という 3 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 --- ## 組み込み -OpenCode には、2 つの組み込みプライマリエージェントと 2 つの組み込みサブエージェントが付属しています。 +OpenCode には、2 つの組み込みプライマリエージェントと 3 つの組み込みサブエージェントが付属しています。 --- @@ -83,6 +83,14 @@ _モード_: `subagent` --- +### Scout + +_モード_: `subagent` + +外部ドキュメントや依存関係の調査を行うための読み取り専用エージェントです。依存関係のリポジトリを OpenCode の管理キャッシュにクローンしたいとき、ライブラリのソースコードを調べたいとき、あるいはワークスペースを変更せずにローカルコードを upstream の実装と突き合わせたいときに使用します。 + +--- + ### Compact _モード_: `primary` diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 34de6250d1d3..02f31c5b6201 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -35,13 +35,13 @@ OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되 subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. +OpenCode에는 기본 제공 subagent인 **General**, **Explore**, **Scout**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 3개를 제공합니다. --- @@ -83,6 +83,14 @@ _Mode_: `subagent` --- +### Use Scout + +_Mode_: `subagent` + +외부 docs와 dependency 리서치를 위한 읽기 전용 agent입니다. dependency repository를 OpenCode의 관리형 cache에 clone하거나, 라이브러리 소스를 살펴보거나, workspace를 수정하지 않고 로컬 코드를 upstream 구현과 교차 확인해야 할 때 사용하세요. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/nb/agents.mdx b/packages/web/src/content/docs/nb/agents.mdx index d7831e3387db..f9971758d54a 100644 --- a/packages/web/src/content/docs/nb/agents.mdx +++ b/packages/web/src/content/docs/nb/agents.mdx @@ -35,13 +35,13 @@ OpenCode kommer med to innebygde primære agenter, **Build** og **Plan**. Vi ser Underagenter er spesialiserte assistenter som primære agenter kan påkalle for spesifikke oppgaver. Du kan også starte dem manuelt ved å **@ nevne** dem i meldingene dine. -OpenCode kommer med to innebygde underagenter, **General** og **Explore**. Vi skal se på dette nedenfor. +OpenCode kommer med tre innebygde underagenter, **General**, **Explore** og **Scout**. Vi skal se på dette nedenfor. --- ## Innebygd -OpenCode kommer med to innebygde primære agenter og to innebygde underagenter. +OpenCode kommer med to innebygde primære agenter og tre innebygde underagenter. --- @@ -83,6 +83,14 @@ En rask, skrivebeskyttet agent for å utforske kodebaser. Kan ikke endre filer. --- +### Bruk av Scout + +_Modus_: `subagent` + +En skrivebeskyttet agent for ekstern dokumentasjon og forskning på avhengigheter. Bruk denne når du trenger å klone et avhengighetsrepo inn i OpenCode sin administrerte cache, inspisere kildekoden til et bibliotek eller kryssjekke lokal kode mot upstream-implementasjoner uten å endre arbeidsområdet ditt. + +--- + ### Bruk av Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/pl/agents.mdx b/packages/web/src/content/docs/pl/agents.mdx index 7a4d7a9960c0..8cf9561e16e7 100644 --- a/packages/web/src/content/docs/pl/agents.mdx +++ b/packages/web/src/content/docs/pl/agents.mdx @@ -35,13 +35,13 @@ OpenCode zawiera dwa wbudowane agenty główne: **Build** i **Plan**. Przyjrzymy Subagenci to asystenci pomocniczy, których mogą przywoływać agenci główni w celu wykonania konkretnych zadań. Możesz także wywoływać ich ręcznie, **wzmiankując ich (@)** w swoich wiadomościach. -OpenCode ma dwóch wbudowanych subagentów: **General** i **Explore**. Przyjrzymy się im poniżej. +OpenCode ma trzech wbudowanych subagentów: **General**, **Explore** i **Scout**. Przyjrzymy się im poniżej. --- ## Wbudowane -OpenCode ma dwa wbudowane agenty główne i dwa wbudowane subagenty. +OpenCode ma dwa wbudowane agenty główne i trzech wbudowanych subagentów. --- @@ -83,6 +83,14 @@ Szybki agent tylko do odczytu do eksploracji baz kodu. Nie może modyfikować pl --- +### Scout + +_Mode_: `subagent` + +Agent tylko do odczytu do pracy z zewnętrzną dokumentacją i badaniem zależności. Używaj go, gdy chcesz sklonować repozytorium zależności do zarządzanej pamięci podręcznej OpenCode, przejrzeć kod źródłowy biblioteki albo porównać lokalny kod z implementacjami upstream bez modyfikowania swojego workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/pt-br/agents.mdx b/packages/web/src/content/docs/pt-br/agents.mdx index 9a831e80489e..815264d840ce 100644 --- a/packages/web/src/content/docs/pt-br/agents.mdx +++ b/packages/web/src/content/docs/pt-br/agents.mdx @@ -36,13 +36,13 @@ ver isso abaixo. Subagentes são assistentes especializados que agentes primários podem invocar para tarefas específicas. Você também pode invocá-los manualmente mencionando-os com **@** em suas mensagens. -opencode vem com dois subagentes integrados, **General** e **Explore**. Vamos ver isso abaixo. +OpenCode vem com três subagentes integrados, **General**, **Explore** e **Scout**. Vamos ver isso abaixo. --- ## Integrados -opencode vem com dois agentes primários integrados e dois subagentes integrados. +OpenCode vem com dois agentes primários integrados e três subagentes integrados. --- @@ -84,6 +84,14 @@ Um agente rápido e somente leitura para explorar bases de código. Não pode mo --- +### Scout + +_Modo_: `subagent` + +Um agente somente leitura para pesquisa em documentação externa e dependências. Use-o quando você precisar clonar o repositório de uma dependência para o cache gerenciado do OpenCode, inspecionar o código-fonte de uma biblioteca ou cruzar o código local com implementações upstream sem modificar seu workspace. + +--- + ### compaction _Modo_: `primary` diff --git a/packages/web/src/content/docs/ru/agents.mdx b/packages/web/src/content/docs/ru/agents.mdx index f515c15d7bfc..767cbf862fe3 100644 --- a/packages/web/src/content/docs/ru/agents.mdx +++ b/packages/web/src/content/docs/ru/agents.mdx @@ -35,13 +35,13 @@ opencode поставляется с двумя встроенными осно Субагенты — это специализированные помощники, которых основные агенты могут вызывать для выполнения определенных задач. Вы также можете вызвать их вручную, **@ упомянув** их в своих сообщениях. -opencode поставляется с двумя встроенными субагентами: **General** и **Explore**. Мы рассмотрим это ниже. +OpenCode поставляется с тремя встроенными субагентами: **General**, **Explore** и **Scout**. Мы рассмотрим их ниже. --- ## Встроенные агенты -opencode поставляется с двумя встроенными основными агентами и двумя встроенными субагентами. +OpenCode поставляется с двумя встроенными основными агентами и тремя встроенными субагентами. --- @@ -83,6 +83,14 @@ _Режим_: `subagent` --- +### Использование Scout + +_Режим_: `subagent` + +Агент только для чтения для работы с внешней документацией и исследования зависимостей. Используйте его, когда нужно клонировать репозиторий зависимости в управляемый кэш OpenCode, изучить исходный код библиотеки или сверить локальный код с upstream-реализациями без изменений в рабочем пространстве. + +--- + ### Использование Compact _Режим_: `primary` diff --git a/packages/web/src/content/docs/th/agents.mdx b/packages/web/src/content/docs/th/agents.mdx index 567125aced0b..e37df6ce475e 100644 --- a/packages/web/src/content/docs/th/agents.mdx +++ b/packages/web/src/content/docs/th/agents.mdx @@ -36,13 +36,13 @@ OpenCode มีเอเจนต์หลักในตัวได้แก Subagent คือผู้ช่วยเฉพาะทางที่ Primary Agent สามารถเรียกใช้งานได้ หรือคุณสามารถเรียกใช้โดยตรงโดยพิมพ์ **@** ตามด้วยชื่อเอเจนต์ในข้อความของคุณ -OpenCode มี subagent ในตัวได้แก่ **General** และ **Explore** +OpenCode มี subagent ในตัวได้แก่ **General**, **Explore** และ **Scout** ดูรายละเอียดด้านล่าง --- ## บิวท์อิน -OpenCode มาพร้อมกับเอเจนต์หลักและ subagent ในตัวดังนี้ +OpenCode มาพร้อมกับเอเจนต์หลัก 2 ตัวและ subagent ในตัว 3 ตัว --- @@ -84,6 +84,14 @@ _Mode_: `subagent` --- +### Scout + +_Mode_: `subagent` + +เอเจนต์แบบอ่านอย่างเดียวสำหรับค้นคว้าเอกสารภายนอกและ dependency ใช้สิ่งนี้เมื่อคุณต้องการ clone repository ของ dependency เข้าไปใน cache ที่ OpenCode จัดการให้, ตรวจสอบ source code ของไลบรารี, หรือเทียบโค้ดในเครื่องกับ implementation จาก upstream โดยไม่แก้ไข workspace ของคุณ + +--- + ### Compact _Mode_: `primary` diff --git a/packages/web/src/content/docs/tr/agents.mdx b/packages/web/src/content/docs/tr/agents.mdx index 1f582511be09..c523b2b3bf94 100644 --- a/packages/web/src/content/docs/tr/agents.mdx +++ b/packages/web/src/content/docs/tr/agents.mdx @@ -35,13 +35,13 @@ opencode, **Build** ve **Plan** olmak üzere iki yerleşik birincil agent ile bi Alt agent'lar, birincil agent'ların belirli görevler için çağırabileceği uzman yardımcılardır. Ayrıca mesajlarınızda **@ bahsederek** bunları manuel olarak da çağırabilirsiniz. -opencode, **General** ve **Explore** olmak üzere iki yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. +OpenCode, **General**, **Explore** ve **Scout** olmak üzere üç yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. --- ## Yerleşik -opencode iki yerleşik birincil agent ve iki yerleşik alt agent ile birlikte gelir. +OpenCode iki yerleşik birincil agent ve üç yerleşik alt agent ile birlikte gelir. --- @@ -83,6 +83,14 @@ Kod tabanlarını keşfetmeye yönelik hızlı, salt okunur bir agent. Dosyalar --- +### Scout Kullanımı + +_Mod_: `subagent` + +Harici dokümanlar ve bağımlılık araştırmaları için salt okunur bir agent. Bir bağımlılık repository'sini OpenCode'un yönetilen cache'ine clone etmeniz, kütüphane kaynak kodunu incelemeniz veya workspace'inizi değiştirmeden yerel kodu upstream implementasyonlarla karşılaştırmanız gerektiğinde bunu kullanın. + +--- + ### Compaction Kullanımı _Mod_: `primary` diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx index 2087c683668a..6f821ff7f869 100644 --- a/packages/web/src/content/docs/zh-cn/agents.mdx +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -35,13 +35,13 @@ OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面 子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 -OpenCode 内置了两个子代理:**General** 和 **Explore**。我们将在下面介绍它们。 +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 --- ## 内置代理 -OpenCode 内置了两个主代理和两个子代理。 +OpenCode 内置了两个主代理和三个子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx index fa8f10254348..a9c7bbadbf23 100644 --- a/packages/web/src/content/docs/zh-tw/agents.mdx +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -35,13 +35,13 @@ OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面 子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 -OpenCode 內建了兩個子代理:**General** 和 **Explore**。我們將在下面介紹它們。 +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 --- ## 內建代理 -OpenCode 內建了兩個主代理和兩個子代理。 +OpenCode 內建了兩個主代理和三個子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` From 343e68853c2d4611ebb986997d8032cfec069cdd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:34:45 +0530 Subject: [PATCH 50/84] fix(scout): type repo tool definitions --- packages/opencode/src/tool/repo_clone.ts | 16 +++++++++++++--- packages/opencode/src/tool/repo_overview.ts | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 0b22ae6432e3..108890c99834 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -15,6 +15,16 @@ const parameters = z.object({ refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"), }) +type Metadata = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + function statusForRepository(input: { reuse: boolean; refresh?: boolean }) { if (!input.reuse) return "cloned" as const if (input.refresh) return "refreshed" as const @@ -34,7 +44,7 @@ function resetTarget(input: { return "HEAD" } -export const RepoCloneTool = Tool.define( +export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -43,7 +53,7 @@ export const RepoCloneTool = Tool.define( return { description: DESCRIPTION, parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const reference = parseRepositoryReference(params.repository) if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") @@ -137,6 +147,6 @@ export const RepoCloneTool = Tool.define( (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), ) }).pipe(Effect.orDie), - } + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index 650bc352f1f7..77f65e3488e2 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -147,7 +147,7 @@ export const RepoOverviewTool = Tool.define, ctx: Tool.Context) => + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) const depth = params.depth ?? 3 @@ -233,6 +233,6 @@ export const RepoOverviewTool = Tool.define }), ) From 35a19df57d670dd778b6420897eec388b2ad88a0 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:38:37 +0530 Subject: [PATCH 51/84] fix(scout): widen repo tool schema types --- packages/opencode/src/tool/repo_clone.ts | 9 +++++++-- packages/opencode/src/tool/repo_overview.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 108890c99834..1d3c80a41396 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -8,7 +8,12 @@ import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" -const parameters = z.object({ +type Parameters = { + repository: string + refresh?: boolean +} + +const parameters: z.ZodType = z.object({ repository: z .string() .describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"), @@ -53,7 +58,7 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const reference = parseRepositoryReference(params.repository) if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index 77f65e3488e2..f991a2e0fa78 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -9,7 +9,13 @@ import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" import { Instance } from "@/project/instance" -const parameters = z +type Parameters = { + repository?: string + path?: string + depth?: number +} + +const parameters: z.ZodType = z .object({ repository: z .string() @@ -84,7 +90,7 @@ export const RepoOverviewTool = Tool.define) { + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Parameters) { if (params.path) { const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) return { path: full, repository: params.repository } @@ -147,7 +153,7 @@ export const RepoOverviewTool = Tool.define, ctx: Tool.Context) => + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) const depth = params.depth ?? 3 From c750df3e86d180c3f6b3fcd936d9133e878166d8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 18:58:28 +0530 Subject: [PATCH 52/84] fix(scout): use effect schema tool params --- packages/opencode/src/tool/repo_clone.ts | 28 ++++++------- packages/opencode/src/tool/repo_overview.ts | 46 +++++++++------------ 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 1d3c80a41396..41fd2c5fd1da 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flock } from "@opencode-ai/shared/util/flock" import { Git } from "@/git" @@ -8,16 +7,13 @@ import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" -type Parameters = { - repository: string - refresh?: boolean -} - -const parameters: z.ZodType = z.object({ - repository: z - .string() - .describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"), - refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"), +export const Parameters = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + refresh: Schema.optional(Schema.Boolean).annotate({ + description: "When true, fetches the latest remote state into the managed cache", + }), }) type Metadata = { @@ -49,7 +45,7 @@ function resetTarget(input: { return "HEAD" } -export const RepoCloneTool = Tool.define( +export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -57,8 +53,8 @@ export const RepoCloneTool = Tool.define) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const reference = parseRepositoryReference(params.repository) if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") @@ -152,6 +148,6 @@ export const RepoCloneTool = Tool.define Effect.promise(() => lock.release()).pipe(Effect.ignore), ) }).pipe(Effect.orDie), - } satisfies Tool.DefWithoutID + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index f991a2e0fa78..fcdd1e7da39e 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" import { assertExternalDirectoryEffect } from "./external-directory" @@ -9,24 +8,17 @@ import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" import { Instance } from "@/project/instance" -type Parameters = { - repository?: string - path?: string - depth?: number -} - -const parameters: z.ZodType = z - .object({ - repository: z - .string() - .optional() - .describe("Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand"), - path: z.string().optional().describe("Directory path to inspect instead of a cached repository"), - depth: z.number().int().positive().max(6).optional().describe("Maximum structure depth to include. Defaults to 3."), - }) - .refine((input) => Boolean(input.repository || input.path), { - message: "Either repository or path is required", +export const Parameters = Schema.Struct({ + repository: Schema.optional(Schema.String).annotate({ + description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Directory path to inspect instead of a cached repository", + }), + depth: Schema.optional(Schema.Number).annotate({ + description: "Maximum structure depth to include. Defaults to 3.", }) +}) type Metadata = { path: string @@ -84,19 +76,21 @@ function commonEntrypoints(files: Set) { return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file)) } -export const RepoOverviewTool = Tool.define( +export const RepoOverviewTool = Tool.define( "repo_overview", Effect.gen(function* () { const fs = yield* AppFileSystem.Service const git = yield* Git.Service - const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Parameters) { + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Schema.Schema.Type) { if (params.path) { const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) return { path: full, repository: params.repository } } - const parsed = parseRepositoryReference(params.repository!) + if (!params.repository) throw new Error("Either repository or path is required") + + const parsed = parseRepositoryReference(params.repository) if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") const repository = parsed.label @@ -152,11 +146,11 @@ export const RepoOverviewTool = Tool.define) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) - const depth = params.depth ?? 3 + const depth = !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) yield* ctx.ask({ @@ -239,6 +233,6 @@ export const RepoOverviewTool = Tool.define + } satisfies Tool.DefWithoutID }), ) From b633a8b1c828988ac350e7c3aa9299c6cf7d7227 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 19:03:56 +0530 Subject: [PATCH 53/84] refactor(scout): fold github remote parsing into repository --- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/util/github-remote.ts | 34 --------------------- packages/opencode/src/util/repository.ts | 9 ++++++ 3 files changed, 10 insertions(+), 35 deletions(-) delete mode 100644 packages/opencode/src/util/github-remote.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index c44b58d6a44e..898b7331286f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -33,7 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util" -import { parseGitHubRemote } from "@/util/github-remote" +import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" type GitHubAuthor = { diff --git a/packages/opencode/src/util/github-remote.ts b/packages/opencode/src/util/github-remote.ts deleted file mode 100644 index fc30e2cfcfde..000000000000 --- a/packages/opencode/src/util/github-remote.ts +++ /dev/null @@ -1,34 +0,0 @@ -function normalize(input: string) { - return input.trim().replace(/^git\+/, "").replace(/#.*$/, "") -} - -export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = normalize(url).match(/^(?:(?:https?|ssh|git):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} - -export function parseGitHubRepository(input: string): { owner: string; repo: string } | null { - const cleaned = normalize(input) - const remote = parseGitHubRemote(cleaned) - if (remote) return remote - - const prefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) - if (prefixed) { - return { owner: prefixed[1], repo: prefixed[2].replace(/\.git$/, "") } - } - - const match = cleaned.match(/^([^/\s]+)\/([^/\s]+)$/) - if (!match) return null - return { owner: match[1], repo: match[2].replace(/\.git$/, "") } -} - -export function githubRepositoryURL(input: { owner: string; repo: string }) { - return `https://github.com/${input.owner}/${input.repo}` -} - -export function githubCloneURL(input: { owner: string; repo: string }) { - const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - if (!base) return `https://github.com/${input.owner}/${input.repo}.git` - return new URL(`${input.owner}/${input.repo}.git`, base.endsWith("/") ? base : `${base}/`).href -} diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index f9ffb0e49cd4..30c680b4ee2b 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -88,6 +88,15 @@ export function parseRepositoryReference(input: string) { } } +export function parseGitHubRemote(input: string) { + const cleaned = normalize(input) + if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null + + const parsed = parseRepositoryReference(cleaned) + if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null + return { owner: parsed.owner, repo: parsed.repo } +} + export function repositoryCachePath(input: Reference) { return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) } From 971c837ad479c21950f7ec0b3dcfb2a5e7b24c40 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:08:24 +0530 Subject: [PATCH 54/84] feat(task): add background subagent support --- packages/opencode/src/session/prompt.ts | 4 + packages/opencode/src/tool/registry.ts | 11 +- packages/opencode/src/tool/task.ts | 182 +++++++++--- packages/opencode/src/tool/task.txt | 12 +- packages/opencode/src/tool/task_status.ts | 145 +++++++++ packages/opencode/src/tool/task_status.txt | 13 + packages/opencode/test/tool/task.test.ts | 185 +++++++++++- .../opencode/test/tool/task_status.test.ts | 278 ++++++++++++++++++ 8 files changed, 775 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/src/tool/task_status.ts create mode 100644 packages/opencode/src/tool/task_status.txt create mode 100644 packages/opencode/test/tool/task_status.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5f3530bcefa7..c96f655bd0d3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -115,6 +115,10 @@ export const layer = Layer.effect( cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), + loop: (input: LoopInput) => loop(input), + fork: (effect: Effect.Effect) => { + run.fork(effect) + }, } satisfies TaskPromptOps }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 539ad632020c..eb0c75c7ac3f 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -7,6 +7,7 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" +import { TaskStatusTool } from "./task_status" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" @@ -47,6 +48,7 @@ import { Bus } from "../bus" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" +import { SessionStatus } from "@/session/status" const log = Log.create({ service: "tool.registry" }) @@ -78,8 +80,9 @@ export const layer: Layer.Layer< | Todo.Service | Agent.Service | Skill.Service - | Session.Service - | Provider.Service + | Session.Service + | SessionStatus.Service + | Provider.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -115,6 +118,7 @@ export const layer: Layer.Layer< const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const taskstatus = yield* TaskStatusTool const agent = yield* Agent.Service const state = yield* InstanceState.make( @@ -195,6 +199,7 @@ export const layer: Layer.Layer< edit: Tool.init(edit), write: Tool.init(writetool), task: Tool.init(task), + taskstatus: Tool.init(taskstatus), fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), @@ -218,6 +223,7 @@ export const layer: Layer.Layer< tool.edit, tool.write, tool.task, + tool.taskstatus, tool.fetch, tool.todo, tool.search, @@ -335,6 +341,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 5cb0dc6a8361..2bc58cdbb4e5 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,17 +1,22 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" +import { Bus } from "../bus" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" +import { SessionStatus } from "../session/status" import { Config } from "../config" -import { Effect, Schema } from "effect" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Cause, Effect, Option, Schema } from "effect" export interface TaskPromptOps { cancel(sessionID: SessionID): void resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect + loop(input: SessionPrompt.LoopInput): Effect.Effect + fork(effect: Effect.Effect): void } const id = "task" @@ -20,19 +25,61 @@ export const Parameters = Schema.Struct({ description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), - task_id: Schema.optional(Schema.String).annotate({ + task_id: Schema.optional(SessionID).annotate({ description: "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), + background: Schema.optional(Schema.Boolean).annotate({ + description: "When true, launch the subagent in the background and return immediately", + }), }) +function output(sessionID: SessionID, text: string) { + return [ + `task_id: ${sessionID} (for resuming to continue this task if needed)`, + "", + "", + text, + "", + ].join("\n") +} + +function backgroundOutput(sessionID: SessionID) { + return [ + `task_id: ${sessionID} (for polling this task with task_status)`, + "state: running", + "", + "", + "Background task started. Continue your current work and call task_status when you need the result.", + "", + ].join("\n") +} + +function backgroundMessage(input: { sessionID: SessionID; description: string; state: "completed" | "error"; text: string }) { + const tag = input.state === "completed" ? "task_result" : "task_error" + const title = + input.state === "completed" + ? `Background task completed: ${input.description}` + : `Background task failed: ${input.description}` + return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, `<${tag}>`, input.text, ``].join( + "\n", + ) +} + +function errorText(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + export const TaskTool = Tool.define( id, Effect.gen(function* () { const agent = yield* Agent.Service + const bus = yield* Bus.Service const config = yield* Config.Service const sessions = yield* Session.Service + const status = yield* SessionStatus.Service const run = Effect.fn("TaskTool.execute")(function* ( params: Schema.Schema.Type, @@ -62,7 +109,7 @@ export const TaskTool = Tool.define( const taskID = params.task_id const session = taskID - ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + ? yield* sessions.get(taskID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const nextSession = session ?? @@ -103,19 +150,107 @@ export const TaskTool = Tool.define( modelID: msg.info.modelID, providerID: msg.info.providerID, } + const parentModel = { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + const background = params.background === true + + const metadata = { + sessionId: nextSession.id, + model, + ...(background ? { background: true } : {}), + } yield* ctx.metadata({ title: params.description, - metadata: { - sessionId: nextSession.id, - model, - }, + metadata, }) const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) - const messageID = MessageID.ascending() + const runTask = Effect.fn("TaskTool.runTask")(function* () { + const parts = yield* ops.resolvePromptParts(params.prompt) + const result = yield* ops.prompt({ + messageID: MessageID.ascending(), + sessionID: nextSession.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: next.name, + tools: { + ...(canTodo ? {} : { todowrite: false }), + ...(canTask ? {} : { task: false }), + ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), + }, + parts, + }) + return result.parts.findLast((item) => item.type === "text")?.text ?? "" + }) + + const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: { + userID: MessageID + state: "completed" | "error" + }) { + if ((yield* status.get(ctx.sessionID)).type !== "idle") return + const latest = yield* sessions.findMessage(ctx.sessionID, (item) => item.info.role === "user") + if (Option.isNone(latest)) return + if (latest.value.info.id !== input.userID) return + yield* bus.publish(TuiEvent.ToastShow, { + title: input.state === "completed" ? "Background task complete" : "Background task failed", + message: + input.state === "completed" + ? `Background task \"${params.description}\" finished. Resuming the main thread.` + : `Background task \"${params.description}\" failed. Resuming the main thread.`, + variant: input.state === "completed" ? "success" : "error", + duration: 5000, + }) + yield* ops.loop({ sessionID: ctx.sessionID }).pipe(Effect.ignore) + }) + + if (background) { + const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (state: "completed" | "error", text: string) { + const message = yield* ops.prompt({ + sessionID: ctx.sessionID, + noReply: true, + model: parentModel, + agent: ctx.agent, + parts: [ + { + type: "text", + synthetic: true, + text: backgroundMessage({ + sessionID: nextSession.id, + description: params.description, + state, + text, + }), + }, + ], + }) + yield* continueIfIdle({ userID: message.info.id, state }) + }) + + ops.fork( + runTask().pipe( + Effect.matchCauseEffect({ + onSuccess: (text) => inject("completed", text), + onFailure: (cause) => + inject("error", errorText(Cause.squash(cause))).pipe(Effect.catchCause(() => Effect.void)), + }), + Effect.catchCause(() => Effect.void), + Effect.asVoid, + ), + ) + + return { + title: params.description, + metadata, + output: backgroundOutput(nextSession.id), + } + } function cancel() { ops.cancel(nextSession.id) @@ -127,36 +262,11 @@ export const TaskTool = Tool.define( }), () => Effect.gen(function* () { - const parts = yield* ops.resolvePromptParts(params.prompt) - const result = yield* ops.prompt({ - messageID, - sessionID: nextSession.id, - model: { - modelID: model.modelID, - providerID: model.providerID, - }, - agent: next.name, - tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), - ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), - }, - parts, - }) - + const text = yield* runTask() return { title: params.description, - metadata: { - sessionId: nextSession.id, - model, - }, - output: [ - `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, - "", - "", - result.parts.findLast((item) => item.type === "text")?.text ?? "", - "", - ].join("\n"), + metadata, + output: output(nextSession.id, text), } }), () => diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index fba8470d1b4b..5d26066a8c2a 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -14,11 +14,13 @@ When NOT to use the Task tool: Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses -2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. -3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. -4. The agent's outputs should generally be trusted -5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). -6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +2. By default, task waits for completion and returns the result immediately, along with a task_id you can reuse later to continue the same subagent session. +3. Set background=true to launch asynchronously. In background mode, continue your current work without waiting. +4. For background runs, use task_status(task_id=..., wait=false) to poll, or wait=true to block until done (optionally with timeout_ms). +5. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +6. The agent's outputs should generally be trusted +7. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +8. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): diff --git a/packages/opencode/src/tool/task_status.ts b/packages/opencode/src/tool/task_status.ts new file mode 100644 index 000000000000..60d5218a387c --- /dev/null +++ b/packages/opencode/src/tool/task_status.ts @@ -0,0 +1,145 @@ +import * as Tool from "./tool" +import DESCRIPTION from "./task_status.txt" +import { Session } from "../session" +import { SessionID } from "../session/schema" +import { MessageV2 } from "../session/message-v2" +import { SessionStatus } from "../session/status" +import { PositiveInt } from "@/util/schema" +import { Effect, Option, Schema } from "effect" + +const DEFAULT_TIMEOUT = 60_000 +const POLL_MS = 300 + +const Parameters = Schema.Struct({ + task_id: SessionID.annotate({ description: "The task_id returned by the task tool" }), + wait: Schema.optional(Schema.Boolean).annotate({ description: "When true, wait until the task reaches a terminal state or timeout" }), + timeout_ms: Schema.optional(PositiveInt).annotate({ + description: "Maximum milliseconds to wait when wait=true (default: 60000)", + }), +}) + +type State = "running" | "completed" | "error" +type InspectResult = { state: State; text: string } + +function format(input: { taskID: SessionID; state: State; text: string }) { + return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", "", input.text, ""].join( + "\n", + ) +} + +function errorText(error: NonNullable) { + const data = Reflect.get(error, "data") + const message = data && typeof data === "object" ? Reflect.get(data, "message") : undefined + if (typeof message === "string" && message) return message + return error.name +} + +export const TaskStatusTool = Tool.define( + "task_status", + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + + const inspect: (taskID: SessionID) => Effect.Effect = Effect.fn("TaskStatusTool.inspect")(function* ( + taskID: SessionID, + ) { + const current = yield* status.get(taskID) + if (current.type === "busy" || current.type === "retry") { + return { + state: "running" as const, + text: current.type === "retry" ? `Task is retrying: ${current.message}` : "Task is still running.", + } + } + + const latestAssistant = yield* sessions.findMessage(taskID, (item) => item.info.role === "assistant") + if (Option.isNone(latestAssistant)) { + return { + state: "running" as const, + text: "Task has started but has not produced output yet.", + } + } + if (latestAssistant.value.info.role !== "assistant") { + return { + state: "running" as const, + text: "Task has started but has not produced output yet.", + } + } + + const latestUser = yield* sessions.findMessage(taskID, (item) => item.info.role === "user") + if (Option.isSome(latestUser) && latestUser.value.info.role === "user" && latestUser.value.info.id > latestAssistant.value.info.id) { + return { + state: "running" as const, + text: "Task is starting.", + } + } + + const text = latestAssistant.value.parts.findLast((part) => part.type === "text")?.text ?? "" + if (latestAssistant.value.info.error) { + return { + state: "error" as const, + text: text || errorText(latestAssistant.value.info.error), + } + } + + const done = + !!latestAssistant.value.info.finish && !["tool-calls", "unknown"].includes(latestAssistant.value.info.finish) + if (done) { + return { + state: "completed" as const, + text, + } + } + + return { + state: "running" as const, + text: text || "Task is still running.", + } + }) + + const waitForTerminal: (taskID: SessionID, timeout: number) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = + Effect.fn("TaskStatusTool.waitForTerminal")(function* (taskID: SessionID, timeout: number) { + const result = yield* inspect(taskID) + if (result.state !== "running") return { result, timedOut: false } + if (timeout <= 0) return { result, timedOut: true } + const sleep = Math.min(POLL_MS, timeout) + yield* Effect.sleep(`${sleep} millis`) + return yield* waitForTerminal(taskID, timeout - sleep) + }) + + const run = Effect.fn("TaskStatusTool.execute")(function* ( + params: Schema.Schema.Type, + _ctx: Tool.Context, + ) { + yield* sessions.get(params.task_id) + + const waited = + params.wait === true + ? yield* waitForTerminal(params.task_id, params.timeout_ms ?? DEFAULT_TIMEOUT) + : { result: yield* inspect(params.task_id), timedOut: false } + + const outputText = waited.timedOut + ? `Timed out after ${params.timeout_ms ?? DEFAULT_TIMEOUT}ms while waiting for task completion.` + : waited.result.text + + return { + title: "Task status", + metadata: { + task_id: params.task_id, + state: waited.result.state, + timed_out: waited.timedOut, + }, + output: format({ + taskID: params.task_id, + state: waited.result.state, + text: outputText, + }), + } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/task_status.txt b/packages/opencode/src/tool/task_status.txt new file mode 100644 index 000000000000..3f8af0d609b4 --- /dev/null +++ b/packages/opencode/src/tool/task_status.txt @@ -0,0 +1,13 @@ +Poll the status of a subagent task launched with the task tool. + +Use this to check background tasks started with `task(background=true)`. + +Parameters: +- `task_id` (required): the task session id returned by the task tool +- `wait` (optional): when true, wait for completion +- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true` + +Returns compact, parseable output: +- `task_id` +- `state` (`running`, `completed`, or `error`) +- `...` containing final output, error summary, or current progress text diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b94dd5208655..95ee49a0c2ed 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,13 +1,15 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" import { Config } from "../../src/config" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "../../src/tool" @@ -27,9 +29,11 @@ const ref = { const it = testEffect( Layer.mergeAll( Agent.defaultLayer, + Bus.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, + SessionStatus.defaultLayer, Truncate.defaultLayer, ToolRegistry.defaultLayer, ), @@ -64,15 +68,59 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { return { chat, assistant } }) -function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { +function stubOps(session: Session.Interface, opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { cancel() {}, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => - Effect.sync(() => { + Effect.gen(function* () { opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done") + const userID = input.messageID ?? MessageID.ascending() + const user: MessageV2.User = { + id: userID, + role: "user", + sessionID: input.sessionID, + agent: input.agent ?? "build", + model: input.model ?? ref, + tools: input.tools, + time: { created: Date.now() }, + } + yield* session.updateMessage(user) + + const parts = input.parts.map((part) => ({ + ...part, + id: part.id ?? PartID.ascending(), + messageID: user.id, + sessionID: input.sessionID, + })) + yield* Effect.forEach(parts, (part) => session.updatePart(part), { discard: true }) + + if (input.noReply) { + return { + info: user, + parts, + } + } + + const result = reply({ ...input, messageID: user.id }, opts?.text ?? "done") + yield* session.updateMessage(result.info) + yield* Effect.forEach(result.parts, (part) => session.updatePart(part), { discard: true }) + return result }), + loop: (input) => + Effect.sync(() => + reply( + { + sessionID: input.sessionID, + messageID: MessageID.ascending(), + agent: "build", + model: ref, + parts: [], + }, + opts?.text ?? "done", + ), + ), + fork() {}, } } @@ -195,7 +243,7 @@ describe("tool.task", () => { const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps(sessions, { text: "resumed", onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { @@ -229,11 +277,12 @@ describe("tool.task", () => { it.live("execute asks by default and skips checks when bypassed", () => provideTmpdirInstance(() => Effect.gen(function* () { + const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() const calls: unknown[] = [] - const promptOps = stubOps() + const promptOps = stubOps(sessions) const exec = (extra?: Record) => def.execute( @@ -282,15 +331,15 @@ describe("tool.task", () => { const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps(sessions, { text: "created", onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", - }, + prompt: "look into the cache key path", + subagent_type: "general", + task_id: SessionID.make("ses_missing"), + }, { sessionID: chat.id, messageID: assistant.id, @@ -322,7 +371,7 @@ describe("tool.task", () => { const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + const promptOps = stubOps(sessions, { onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { @@ -384,4 +433,116 @@ describe("tool.task", () => { }, ), ) + + it.live("execute launches background tasks without waiting for completion", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const forks: Effect.Effect[] = [] + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(sessions), + fork(effect) { + forks.push(effect) + }, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.sessionId).toBeDefined() + expect(result.metadata.background).toBe(true) + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(result.output).toContain("state: running") + expect(forks).toHaveLength(1) + }), + ), + ) + + it.live("background tasks inject completion into the parent session and resume when idle", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const forks: Effect.Effect[] = [] + const loops: string[] = [] + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(sessions, { text: "background done" }), + loop(input) { + loops.push(input.sessionID) + return Effect.sync(() => + reply( + { + sessionID: input.sessionID, + messageID: MessageID.ascending(), + agent: "build", + model: ref, + parts: [], + }, + "looped", + ), + ) + }, + fork(effect) { + forks.push(effect) + }, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + yield* forks[0]! + + const parent = yield* sessions.findMessage(chat.id, (msg) => msg.info.role === "user") + expect(parent._tag).toBe("Some") + if (parent._tag !== "Some") return + expect(parent.value.parts.find((part) => part.type === "text")?.text).toContain("Background task completed") + expect(parent.value.parts.find((part) => part.type === "text")?.text).toContain("background done") + expect(loops).toEqual([chat.id]) + + const child = yield* sessions.findMessage(result.metadata.sessionId, (msg) => msg.info.role === "assistant") + expect(child._tag).toBe("Some") + if (child._tag !== "Some") return + expect(child.value.parts.find((part) => part.type === "text")?.text).toBe("background done") + }), + ), + ) }) diff --git a/packages/opencode/test/tool/task_status.test.ts b/packages/opencode/test/tool/task_status.test.ts new file mode 100644 index 000000000000..6081f6a92333 --- /dev/null +++ b/packages/opencode/test/tool/task_status.test.ts @@ -0,0 +1,278 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer, Scope } from "effect" +import { Agent } from "../../src/agent/agent" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { TaskStatusTool } from "../../src/tool/task_status" +import { Truncate } from "../../src/tool" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + SessionStatus.defaultLayer, + Truncate.defaultLayer, + ), +) + +const seedUser = Effect.fn("TaskStatusToolTest.seedUser")(function* (sessionID: Session.Info["id"]) { + const session = yield* Session.Service + return yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) +}) + +const seedAssistant = Effect.fn("TaskStatusToolTest.seedAssistant")(function* (input: { + sessionID: Session.Info["id"] + text: string + error?: string +}) { + const session = yield* Session.Service + const user = yield* seedUser(input.sessionID) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: input.sessionID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now(), completed: Date.now() }, + finish: "stop", + ...(input.error + ? { + error: new MessageV2.APIError({ + message: input.error, + isRetryable: false, + }).toObject(), + } + : {}), + }) + + yield* session.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID: input.sessionID, + type: "text", + text: input.text, + }) +}) + +describe("tool.task_status", () => { + it.live("returns running while session status is busy", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* status.set(chat.id, { type: "busy" }) + const result = yield* def.execute( + { task_id: chat.id }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: running") + }), + ), + ) + + it.live("returns completed with final task output", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* seedAssistant({ sessionID: chat.id, text: "all done" }) + + const result = yield* def.execute( + { task_id: chat.id }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: completed") + expect(result.output).toContain("all done") + }), + ), + ) + + it.live("wait=true blocks until terminal status", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + const scope = yield* Scope.Scope + + yield* status.set(chat.id, { type: "busy" }) + yield* Effect.gen(function* () { + yield* Effect.sleep("150 millis") + yield* status.set(chat.id, { type: "idle" }) + yield* seedAssistant({ sessionID: chat.id, text: "finished later" }) + }).pipe(Effect.forkIn(scope)) + + const result = yield* def.execute( + { + task_id: chat.id, + wait: true, + timeout_ms: 4_000, + }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: completed") + expect(result.output).toContain("finished later") + }), + ), + ) + + it.live("returns error when child run fails", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* seedAssistant({ sessionID: chat.id, text: "", error: "child failed" }) + + const result = yield* def.execute( + { task_id: chat.id }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: error") + expect(result.output).toContain("child failed") + expect(result.metadata.state).toBe("error") + }), + ), + ) + + it.live("wait=true times out with timed_out metadata", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* status.set(chat.id, { type: "busy" }) + const result = yield* def.execute( + { + task_id: chat.id, + wait: true, + timeout_ms: 80, + }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("Timed out after 80ms") + expect(result.metadata.timed_out).toBe(true) + expect(result.metadata.state).toBe("running") + }), + ), + ) + + it.live("returns running for resumed task with a newer user turn", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* seedAssistant({ sessionID: chat.id, text: "old done" }) + yield* seedUser(chat.id) + + const result = yield* def.execute( + { task_id: chat.id }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: running") + expect(result.output).toContain("Task is starting.") + }), + ), + ) +}) From 7970130720a1acf4a405b5bf587cb880e3273713 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:08:32 +0530 Subject: [PATCH 55/84] fix(ui): label background task cards --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- packages/ui/src/components/message-part.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 c04e58acecae..b6cde459e9f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2000,7 +2000,8 @@ function Task(props: ToolProps) { const content = createMemo(() => { if (!props.input.description) return "" - let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`] + const description = props.metadata.background === true ? `${props.input.description} (background)` : props.input.description + let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`] if (isRunning() && tools().length > 0) { // content[0] += ` · ${tools().length} toolcalls` diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9c0c90c00076..3694b204fbd6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1751,9 +1751,10 @@ ToolRegistry.register({ const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) const tone = createMemo(() => agent().color) const subtitle = createMemo(() => { - const value = props.input.description - if (typeof value === "string" && value) return value - return childSessionId() + const value = typeof props.input.description === "string" && props.input.description ? props.input.description : childSessionId() + if (!value) return value + if (props.metadata.background === true) return `${value} (background)` + return value }) const running = createMemo(() => props.status === "pending" || props.status === "running") From ecde8ab3633b10919b5fa67938282d7e806b121d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:20:04 +0530 Subject: [PATCH 56/84] test(task): update parameter schema snapshot --- .../test/tool/__snapshots__/parameters.test.ts.snap | 5 +++++ packages/opencode/test/tool/parameters.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index eb3fe6cce4df..ffb1b9f55f27 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -334,6 +334,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "background": { + "description": "When true, launch the subagent in the background and return immediately", + "type": "boolean", + }, "command": { "description": "The command that triggered this task", "type": "string", @@ -352,6 +356,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` }, "task_id": { "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + "pattern": "^ses.*", "type": "string", }, }, diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 8ea008a457b4..487e6faa178a 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -220,6 +220,19 @@ describe("tool parameters", () => { const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) expect(parsed.subagent_type).toBe("general") }) + test("accepts optional task_id + command + background", () => { + const parsed = parse(Task, { + description: "d", + prompt: "p", + subagent_type: "general", + task_id: "ses_test", + command: "/cmd", + background: true, + }) + expect(parsed.task_id).toBe("ses_test") + expect(parsed.command).toBe("/cmd") + expect(parsed.background).toBe(true) + }) test("rejects missing prompt", () => { expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) From 1357bb984f8fe70222d468a6ddc03340336ee4c5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:20:04 +0530 Subject: [PATCH 57/84] style: fix background task formatting --- .../src/cli/cmd/tui/routes/session/index.tsx | 3 ++- packages/opencode/src/tool/registry.ts | 6 +++--- packages/opencode/src/tool/task_status.ts | 14 +++++++++++--- packages/opencode/test/tool/task.test.ts | 8 ++++---- packages/ui/src/components/message-part.tsx | 3 ++- 5 files changed, 22 insertions(+), 12 deletions(-) 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 b6cde459e9f8..50e39c7c3a19 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2000,7 +2000,8 @@ function Task(props: ToolProps) { const content = createMemo(() => { if (!props.input.description) return "" - const description = props.metadata.background === true ? `${props.input.description} (background)` : props.input.description + const description = + props.metadata.background === true ? `${props.input.description} (background)` : props.input.description let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`] if (isRunning() && tools().length > 0) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eb0c75c7ac3f..64c38ccb4f0b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -80,9 +80,9 @@ export const layer: Layer.Layer< | Todo.Service | Agent.Service | Skill.Service - | Session.Service - | SessionStatus.Service - | Provider.Service + | Session.Service + | SessionStatus.Service + | Provider.Service | LSP.Service | Instruction.Service | AppFileSystem.Service diff --git a/packages/opencode/src/tool/task_status.ts b/packages/opencode/src/tool/task_status.ts index 60d5218a387c..db7960b48514 100644 --- a/packages/opencode/src/tool/task_status.ts +++ b/packages/opencode/src/tool/task_status.ts @@ -66,7 +66,11 @@ export const TaskStatusTool = Tool.define( } const latestUser = yield* sessions.findMessage(taskID, (item) => item.info.role === "user") - if (Option.isSome(latestUser) && latestUser.value.info.role === "user" && latestUser.value.info.id > latestAssistant.value.info.id) { + if ( + Option.isSome(latestUser) && + latestUser.value.info.role === "user" && + latestUser.value.info.id > latestAssistant.value.info.id + ) { return { state: "running" as const, text: "Task is starting.", @@ -96,8 +100,12 @@ export const TaskStatusTool = Tool.define( } }) - const waitForTerminal: (taskID: SessionID, timeout: number) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = - Effect.fn("TaskStatusTool.waitForTerminal")(function* (taskID: SessionID, timeout: number) { + const waitForTerminal: ( + taskID: SessionID, + timeout: number, + ) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = Effect.fn( + "TaskStatusTool.waitForTerminal", + )(function* (taskID: SessionID, timeout: number) { const result = yield* inspect(taskID) if (result.state !== "running") return { result, timedOut: false } if (timeout <= 0) return { result, timedOut: true } diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 95ee49a0c2ed..a5724630ec76 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -336,10 +336,10 @@ describe("tool.task", () => { const result = yield* def.execute( { description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: SessionID.make("ses_missing"), - }, + prompt: "look into the cache key path", + subagent_type: "general", + task_id: SessionID.make("ses_missing"), + }, { sessionID: chat.id, messageID: assistant.id, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3694b204fbd6..eb90b4d9e633 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1751,7 +1751,8 @@ ToolRegistry.register({ const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) const tone = createMemo(() => agent().color) const subtitle = createMemo(() => { - const value = typeof props.input.description === "string" && props.input.description ? props.input.description : childSessionId() + const value = + typeof props.input.description === "string" && props.input.description ? props.input.description : childSessionId() if (!value) return value if (props.metadata.background === true) return `${value} (background)` return value From 3f4b9d9ef4bf8c8450015c9cc3a7aaa2437058cc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:21:02 +0530 Subject: [PATCH 58/84] test(task): use branded session id in schema test --- packages/opencode/test/tool/parameters.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 487e6faa178a..52d7f44ee305 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -26,6 +26,7 @@ import { Parameters as Todo } from "../../src/tool/todo" import { Parameters as WebFetch } from "../../src/tool/webfetch" import { Parameters as WebSearch } from "../../src/tool/websearch" import { Parameters as Write } from "../../src/tool/write" +import { SessionID } from "../../src/session/schema" const parse = >(schema: S, input: unknown): S["Type"] => Schema.decodeUnknownSync(schema)(input) @@ -225,11 +226,11 @@ describe("tool parameters", () => { description: "d", prompt: "p", subagent_type: "general", - task_id: "ses_test", + task_id: SessionID.make("ses_test"), command: "/cmd", background: true, }) - expect(parsed.task_id).toBe("ses_test") + expect(parsed.task_id).toBe(SessionID.make("ses_test")) expect(parsed.command).toBe("/cmd") expect(parsed.background).toBe(true) }) From 601fe03a3a571b8da3a9aabc52ee869624fe6e61 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 20:30:53 +0530 Subject: [PATCH 59/84] refactor(task): simplify effect wrappers --- packages/opencode/src/tool/task.ts | 12 +++++------- packages/opencode/src/tool/task_status.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2bc58cdbb4e5..61b3294be5eb 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -81,10 +81,9 @@ export const TaskTool = Tool.define( const sessions = yield* Session.Service const status = yield* SessionStatus.Service - const run = Effect.fn("TaskTool.execute")(function* ( - params: Schema.Schema.Type, - ctx: Tool.Context, - ) { + const run = Effect.fn( + "TaskTool.execute", + )(function* (params: Schema.Schema.Type, ctx: Tool.Context) { const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { @@ -274,13 +273,12 @@ export const TaskTool = Tool.define( ctx.abort.removeEventListener("abort", cancel) }), ) - }) + }, Effect.orDie) return { description: DESCRIPTION, parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => - run(params, ctx).pipe(Effect.orDie), + execute: run, } }), ) diff --git a/packages/opencode/src/tool/task_status.ts b/packages/opencode/src/tool/task_status.ts index db7960b48514..eba29576b330 100644 --- a/packages/opencode/src/tool/task_status.ts +++ b/packages/opencode/src/tool/task_status.ts @@ -110,14 +110,13 @@ export const TaskStatusTool = Tool.define( if (result.state !== "running") return { result, timedOut: false } if (timeout <= 0) return { result, timedOut: true } const sleep = Math.min(POLL_MS, timeout) - yield* Effect.sleep(`${sleep} millis`) + yield* Effect.sleep(sleep) return yield* waitForTerminal(taskID, timeout - sleep) }) - const run = Effect.fn("TaskStatusTool.execute")(function* ( - params: Schema.Schema.Type, - _ctx: Tool.Context, - ) { + const run = Effect.fn( + "TaskStatusTool.execute", + )(function* (params: Schema.Schema.Type, _ctx: Tool.Context) { yield* sessions.get(params.task_id) const waited = @@ -142,12 +141,12 @@ export const TaskStatusTool = Tool.define( text: outputText, }), } - }) + }, Effect.orDie) return { description: DESCRIPTION, parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: run, } }), ) From d704110e52ad243a20733cd3c8cf8a97bf3d6dbf Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:04:49 +1000 Subject: [PATCH 60/84] fix: lazy session error schema --- packages/opencode/src/session/session.ts | 3 ++- packages/opencode/src/util/effect-zod.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f4fe3bf8bd73..d08ddda7ce4d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -284,7 +284,8 @@ export const Event = { sessionID: Schema.optional(SessionID), // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so // the derived zod keeps the same discriminated-union shape on the bus. - error: MessageV2.Assistant.fields.error, + // Schema.suspend defers access to break circular init in compiled binaries. + error: Schema.suspend(() => MessageV2.Assistant.fields.error), }), ), } diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76ebf4..1aa0cbec3b99 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -256,6 +256,8 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny { return array(ast) case "Declaration": return decl(ast) + case "Suspend": + return z.lazy(() => walk(ast.thunk())) default: return fail(ast) } From 341b8e78c90ed171a8578e6bf5662e532f69219e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:11:42 +1000 Subject: [PATCH 61/84] perms --- packages/opencode/src/tool/shell/arity.ts | 7 ++----- packages/opencode/test/permission/arity.test.ts | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index 073b62053db9..ec7d7e728a50 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,11 +1,8 @@ import { BashArity } from "@/permission/arity" -import { ShellKind } from "./id" +import type { ShellKind } from "./id" export namespace ShellArity { - export function prefix(tokens: string[], shellType: ShellKind.ID) { - if (ShellKind.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { - return [tokens[0]] - } + export function prefix(tokens: string[], _shellType: ShellKind.ID) { return BashArity.prefix(tokens) } } diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 5e2af7afc1ca..01a0dfba61c4 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -36,4 +36,5 @@ test("powershell verb-noun structures", () => { expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"]) expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"]) expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["redis-cli", "ping"], "pwsh")).toEqual(["redis-cli", "ping"]) }) From 428b0c46a7b1eaf2ffa0c71aa401aed1ecc32920 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:15:31 +1000 Subject: [PATCH 62/84] cmd --- packages/opencode/src/tool/shell.ts | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index e4b273cb1866..683622a0d6ad 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -24,6 +24,7 @@ import { ShellArity } from "./shell/arity" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -55,6 +56,8 @@ const describe = { "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", powershell: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', } function parameterSchema(description: string) { @@ -88,6 +91,7 @@ function renderPrompt(template: string, values: Record) { function shellDisplayName(name: string) { if (name === "pwsh") return "PowerShell (7+)" if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" return name } @@ -120,6 +124,9 @@ function chainGuidance(name: string) { if (PS.has(name)) { return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." } @@ -200,7 +207,7 @@ Usage notes: - Write files: Use Write (NOT Set-Content/Out-File or here-strings) - Communication: Output text directly (NOT Write-Output/Write-Host) - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. - ${chain} - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) @@ -213,9 +220,73 @@ Usage notes: ` } +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + function promptProfile(name: string, platform: NodeJS.Platform, limits: Limits) { const isPowerShell = PS.has(name) const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: describe.cmd, + } + } if (isPowerShell) { return { intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, From ecac4c4e2a3a93c4039c5178e02c83c23acc34a6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:32:18 +1000 Subject: [PATCH 63/84] split prompt/definition from logic --- packages/opencode/src/tool/shell.ts | 296 +-------------------- packages/opencode/src/tool/shell/prompt.ts | 296 +++++++++++++++++++++ 2 files changed, 303 insertions(+), 289 deletions(-) create mode 100644 packages/opencode/src/tool/shell/prompt.ts diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 683622a0d6ad..aad08161a484 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,9 +1,8 @@ -import { Effect, Schema, Stream } from "effect" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./shell/shell.txt" import { Log } from "../util" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" @@ -20,11 +19,13 @@ import { Plugin } from "@/plugin" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellArity } from "./shell/arity" +import { ShellPrompt, type Parameters } from "./shell/prompt" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const PS = new Set(["powershell", "pwsh"]) -const CMD = new Set(["cmd"]) const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -51,277 +52,6 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const describe = { - bash: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - -function parameterSchema(description: string) { - return Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ description }), - }) -} - -export const Parameters = parameterSchema(describe.bash) - -type Parameters = Schema.Schema.Type - -type Limits = { - maxLines: number - maxBytes: number -} - -function renderPrompt(template: string, values: Record) { - return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { - const value = values[key] - if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) - return value - }) -} - -function shellDisplayName(name: string) { - if (name === "pwsh") return "PowerShell (7+)" - if (name === "powershell") return "Windows PowerShell (5.1)" - if (name === "cmd") return "cmd.exe" - return name -} - -function powershellNotes(name: string) { - if (name === "pwsh") { - return `# PowerShell (7+) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - } - if (name === "powershell") { - return `# Windows PowerShell (5.1) shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - } - return "" -} - -function chainGuidance(name: string) { - if (name === "powershell") { - return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - } - if (PS.has(name)) { - return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." - } - if (CMD.has(name)) { - return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." - } - return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." -} - -function bashCommandSection(chain: string, limits: Limits) { - return `Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - ` -} - -function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { - return `${powershellNotes(name)} - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location - - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") - - Examples of proper quoting: - - New-Item -ItemType Directory -Path "My Documents" (correct) - - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) - - & "path with spaces${pathSep}script.ps1" (correct) - - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT Get-ChildItem) - - Content search: Use Grep (NOT Select-String) - - Read files: Use Read (NOT Get-Content) - - Edit files: Use Edit (NOT Set-Content) - - Write files: Use Write (NOT Set-Content/Out-File or here-strings) - - Communication: Output text directly (NOT Write-Output/Write-Host) - - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. - - ${chain} - - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail - - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. - - Use workdir="project${pathSep}subdir" with command: pytest tests - - - ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} - ` -} - -function cmdCommandSection(chain: string, limits: Limits) { - return `# cmd.exe shell notes -- Use double quotes for paths with spaces. -- Use %VAR% for environment variables. -- Use \`if exist\` for existence checks. -- Use \`call\` when invoking batch files from another batch-style command. - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location - - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") - - Examples of proper quoting: - - mkdir "My Documents" (correct) - - mkdir My Documents (incorrect - path is split) - - call "path with spaces\\script.bat" (correct) - - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT dir /s) - - Content search: Use Grep (NOT findstr) - - Read files: Use Read (NOT type) - - Edit files: Use Edit (NOT copy) - - Write files: Use Write (NOT echo > file) - - Communication: Output text directly (NOT echo) - - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. - - ${chain} - - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail - - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. - - Use workdir="project\\subdir" with command: dir - - - cd /d "project\\subdir" && dir - ` -} - -function promptProfile(name: string, platform: NodeJS.Platform, limits: Limits) { - const isPowerShell = PS.has(name) - const chain = chainGuidance(name) - if (CMD.has(name)) { - return { - intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, - workdirSection: - "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", - commandSection: cmdCommandSection(chain, limits), - gitCommands: "git commands", - toolName: "Shell", - gitCommandRestriction: "git commands", - createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", - createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: describe.cmd, - } - } - if (isPowerShell) { - return { - intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, - workdirSection: - "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", - commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), - gitCommands: "git commands", - toolName: "Shell", - gitCommandRestriction: "git commands", - createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", - createPrExample: `gh pr create --title "the pr title" --body @' -## Summary -- <1-3 bullet points> -'@`, - parameterDescription: describe.powershell, - } - } - return { - intro: - "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", - workdirSection: - "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", - commandSection: bashCommandSection(chain, limits), - gitCommands: "bash commands", - toolName: "Bash", - gitCommandRestriction: "git bash commands", - createPrInstruction: - "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", - createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points>`, - parameterDescription: describe.bash, - } -} - type Part = { type: string text: string @@ -829,24 +559,12 @@ export const ShellTool = Tool.define( const shell = Shell.acceptable() const name = Shell.name(shell) const limits = yield* trunc.limits() - const profile = promptProfile(name, process.platform, limits) - const description = renderPrompt(DESCRIPTION, { - intro: profile.intro, - os: process.platform, - shell: name, - workdirSection: profile.workdirSection, - commandSection: profile.commandSection, - gitCommands: profile.gitCommands, - toolName: profile.toolName, - gitCommandRestriction: profile.gitCommandRestriction, - createPrInstruction: profile.createPrInstruction, - createPrExample: profile.createPrExample, - }) + const prompt = ShellPrompt.render(name, process.platform, limits) log.info("shell tool using shell", { shell }) return { - description, - parameters: parameterSchema(profile.parameterDescription), + description: prompt.description, + parameters: prompt.parameters, execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 000000000000..dbad3b91bdaf --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,296 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + toolName: "Bash", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: selected.toolName, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" From 790d181d8a5f6ac35114f5f6494f6dcd9354a71b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:37:32 +1000 Subject: [PATCH 64/84] slight accuracy --- packages/opencode/src/tool/shell/prompt.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index dbad3b91bdaf..d38ae9990fad 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -113,7 +113,7 @@ Usage notes: - Write files: Use Write (NOT echo >/cat < && ` patterns - use `workdir` instead.", commandSection: bashCommandSection(chain, limits), gitCommands: "bash commands", - toolName: "Bash", - gitCommandRestriction: "git bash commands", + toolName: "Shell", + gitCommandRestriction: "git commands", createPrInstruction: "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' From 2051cadcb82f47214b25ddfd393397af3fbcafaa Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:18:30 +1000 Subject: [PATCH 65/84] Update prompt.ts --- packages/opencode/src/tool/shell/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index d38ae9990fad..1401a94ad47c 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -264,7 +264,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) { commandSection: bashCommandSection(chain, limits), gitCommands: "bash commands", toolName: "Shell", - gitCommandRestriction: "git commands", + gitCommandRestriction: "git bash commands", createPrInstruction: "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' From 344dab3839eb364e8748d0331b07005849fa14a0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:59:46 +1000 Subject: [PATCH 66/84] Update next.test.ts --- packages/opencode/test/permission/next.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 1c81c0d05356..f1937f9e6eff 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -158,8 +158,8 @@ test("fromConfig - preserves top-level config key order", () => { const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" }) const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" }) - expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"]) - expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"]) + expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "shell"]) + expect(specificFirst.map((r) => r.permission)).toEqual(["shell", "*"]) expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow") expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny") From 6da2e6fe50e6d9f813a2f4ef7876c784f02babd7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 24 Apr 2026 14:07:31 +0200 Subject: [PATCH 67/84] run: add -i interactive minimal mode --- packages/opencode/src/cli/cmd/run.ts | 724 ++++---- packages/opencode/src/cli/cmd/run/demo.ts | 1302 +++++++++++++++ .../opencode/src/cli/cmd/run/entry.body.ts | 183 ++ .../src/cli/cmd/run/footer.permission.tsx | 487 ++++++ .../src/cli/cmd/run/footer.prompt.tsx | 977 +++++++++++ .../src/cli/cmd/run/footer.question.tsx | 591 +++++++ .../src/cli/cmd/run/footer.subagent.tsx | 192 +++ packages/opencode/src/cli/cmd/run/footer.ts | 705 ++++++++ .../opencode/src/cli/cmd/run/footer.view.tsx | 516 ++++++ packages/opencode/src/cli/cmd/run/otel.ts | 119 ++ .../src/cli/cmd/run/permission.shared.ts | 256 +++ .../opencode/src/cli/cmd/run/prompt.shared.ts | 271 +++ .../src/cli/cmd/run/question.shared.ts | 340 ++++ .../opencode/src/cli/cmd/run/runtime.boot.ts | 202 +++ .../src/cli/cmd/run/runtime.lifecycle.ts | 290 ++++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 235 +++ .../src/cli/cmd/run/runtime.shared.ts | 17 + packages/opencode/src/cli/cmd/run/runtime.ts | 586 +++++++ .../src/cli/cmd/run/scrollback.shared.ts | 92 ++ .../src/cli/cmd/run/scrollback.surface.ts | 370 +++++ .../src/cli/cmd/run/scrollback.writer.tsx | 330 ++++ .../opencode/src/cli/cmd/run/session-data.ts | 942 +++++++++++ .../src/cli/cmd/run/session.shared.ts | 196 +++ packages/opencode/src/cli/cmd/run/splash.ts | 279 ++++ .../src/cli/cmd/run/stream.transport.ts | 876 ++++++++++ packages/opencode/src/cli/cmd/run/stream.ts | 175 ++ .../opencode/src/cli/cmd/run/subagent-data.ts | 746 +++++++++ packages/opencode/src/cli/cmd/run/theme.ts | 641 +++++++ packages/opencode/src/cli/cmd/run/tool.ts | 1472 +++++++++++++++++ packages/opencode/src/cli/cmd/run/trace.ts | 94 ++ packages/opencode/src/cli/cmd/run/types.ts | 289 ++++ .../src/cli/cmd/run/variant.shared.ts | 200 +++ packages/opencode/src/cli/cmd/tui/attach.ts | 2 +- .../src/cli/cmd/tui/component/spinner.tsx | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 6 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../opencode/test/cli/run/entry.body.test.ts | 348 ++++ .../test/cli/run/footer.view.test.tsx | 48 + .../test/cli/run/permission.shared.test.ts | 144 ++ .../test/cli/run/prompt.shared.test.ts | 115 ++ .../test/cli/run/question.shared.test.ts | 115 ++ .../test/cli/run/runtime.boot.test.ts | 165 ++ .../test/cli/run/runtime.queue.test.ts | 248 +++ .../test/cli/run/scrollback.surface.test.ts | 500 ++++++ .../test/cli/run/session-data.test.ts | 383 +++++ .../test/cli/run/session.shared.test.ts | 247 +++ packages/opencode/test/cli/run/stream.test.ts | 55 + .../test/cli/run/stream.transport.test.ts | 712 ++++++++ .../test/cli/run/subagent-data.test.ts | 328 ++++ packages/opencode/test/cli/run/theme.test.ts | 116 ++ .../test/cli/run/variant.shared.test.ts | 152 ++ 51 files changed, 18055 insertions(+), 330 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/demo.ts create mode 100644 packages/opencode/src/cli/cmd/run/entry.body.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.permission.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.prompt.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.question.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.subagent.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.view.tsx create mode 100644 packages/opencode/src/cli/cmd/run/otel.ts create mode 100644 packages/opencode/src/cli/cmd/run/permission.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/prompt.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/question.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.boot.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.queue.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.surface.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.writer.tsx create mode 100644 packages/opencode/src/cli/cmd/run/session-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/session.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/splash.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.transport.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.ts create mode 100644 packages/opencode/src/cli/cmd/run/subagent-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/theme.ts create mode 100644 packages/opencode/src/cli/cmd/run/tool.ts create mode 100644 packages/opencode/src/cli/cmd/run/trace.ts create mode 100644 packages/opencode/src/cli/cmd/run/types.ts create mode 100644 packages/opencode/src/cli/cmd/run/variant.shared.ts create mode 100644 packages/opencode/test/cli/run/entry.body.test.ts create mode 100644 packages/opencode/test/cli/run/footer.view.test.tsx create mode 100644 packages/opencode/test/cli/run/permission.shared.test.ts create mode 100644 packages/opencode/test/cli/run/prompt.shared.test.ts create mode 100644 packages/opencode/test/cli/run/question.shared.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.boot.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.queue.test.ts create mode 100644 packages/opencode/test/cli/run/scrollback.surface.test.ts create mode 100644 packages/opencode/test/cli/run/session-data.test.ts create mode 100644 packages/opencode/test/cli/run/session.shared.test.ts create mode 100644 packages/opencode/test/cli/run/stream.test.ts create mode 100644 packages/opencode/test/cli/run/stream.transport.test.ts create mode 100644 packages/opencode/test/cli/run/subagent-data.test.ts create mode 100644 packages/opencode/test/cli/run/theme.test.ts create mode 100644 packages/opencode/test/cli/run/variant.shared.test.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a9e044f1871b..8f2d46b0f8b9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,3 +1,16 @@ +// CLI entry point for `opencode run`. +// +// Handles three modes: +// 1. Non-interactive (default): sends a single prompt, streams events to +// stdout, and exits when the session goes idle. +// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// with an in-process server (no external HTTP). +// 3. Interactive attach (`--interactive --attach`): connects to a running +// opencode server and runs interactive mode against it. +// +// Also supports `--command` for slash-command execution, `--format json` for +// raw event streaming, `--continue` / `--session` for session resumption, +// and `--fork` for forking before continuing. import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" @@ -6,41 +19,30 @@ import { cmd } from "./cmd" import { Flag } from "@opencode-ai/core/flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" -import { Filesystem } from "../../util" +import { Filesystem } from "@/util" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { Server } from "../../server/server" -import { Provider } from "../../provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -import { Tool } from "../../tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { CodeSearchTool } from "../../tool/codesearch" -import { WebSearchTool } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "../../util" +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" import { AppRuntime } from "@/effect/app-runtime" +import type { RunDemo } from "./run/types" -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart -} +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[0]["model"] -function props(part: ToolPart): ToolProps { - const state = part.state +function pick(value: string | undefined): ModelInput | undefined { + if (!value) return undefined + const [providerID, ...rest] = value.split("/") return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, - } + providerID, + modelID: rest.join("/"), + } as ModelInput +} + +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +51,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,152 +70,22 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) -} - -function glob(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Glob "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.count - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function grep(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Grep "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.matches - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function read(info: ToolProps) { - const file = normalizePath(info.input.filePath) - const pairs = Object.entries(info.input).filter(([key, value]) => { - if (key === "filePath") return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined - inline({ - icon: "→", - title: `Read ${file}`, - ...(description && { description }), - }) -} - -function write(info: ToolProps) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function codesearch(info: ToolProps) { - inline({ - icon: "◇", - title: `Exa Code Search "${info.input.query}"`, - }) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `Exa Web Search "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - const input = info.part.state.input - const status = info.part.state.status - const subagent = - typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown" - const agent = Locale.titlecase(subagent) - const desc = - typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined - const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓" - const name = desc ?? `${agent} Task` - inline({ - icon, - title: name, - description: desc ? `${agent} Agent` : undefined, - }) -} - -function skill(info: ToolProps) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function bash(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) -} +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input + inline(next) + } catch { + inline({ + icon: "⚙", + title: part.tool, + }) + } } export const RunCommand = cmd({ @@ -292,6 +170,11 @@ export const RunCommand = cmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { @@ -299,30 +182,87 @@ export const RunCommand = cmd({ describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, }) + .option("demo", { + type: "string", + choices: ["on", "permission", "question", "mix", "text"], + describe: "enable direct interactive demo slash commands", + }) + .option("demo-text", { + type: "string", + describe: "text used with --demo text", + }) }, handler: async (args) => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.demoText && args.demo !== "text") { + die("--demo-text requires --demo text") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdin.isTTY) { + die("--interactive requires a TTY") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { - if (!args.dir) return undefined + if (!args.dir) return args.attach ? undefined : root if (args.attach) return args.dir + try { - process.chdir(args.dir) + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = (() => { + if (!args.attach) return undefined + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -341,7 +281,7 @@ export const RunCommand = cmd({ if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -351,23 +291,25 @@ export const RunCommand = cmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -375,19 +317,83 @@ export const RunCommand = cmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) + + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } + } + + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } } - if (baseID) return baseID + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -405,44 +411,131 @@ export const RunCommand = cmd({ } } + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === "bash") return bash(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "codesearch") return codesearch(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) } + const sessionID = sess.id function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + process.stdout.write( + JSON.stringify({ + type, + timestamp: Date.now(), + sessionID, + ...data, + }) + EOL, + ) return true } return false } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -460,7 +553,7 @@ export const RunCommand = cmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } inline({ @@ -477,7 +570,7 @@ export const RunCommand = cmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -542,7 +635,7 @@ export const RunCommand = cmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -552,7 +645,7 @@ export const RunCommand = cmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -560,121 +653,106 @@ export const RunCommand = cmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent - - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) - - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } + const agent = await pickAgent(client) - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } + await share(client, sessionID) - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) + }) - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, model, variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + await runInteractiveMode({ + sdk: client, + directory: cwd, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, + agent, + model, + variant: args.variant, + files, + initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined, + thinking, + demo: args.demo as RunDemo | undefined, + demoText: args.demoText, + }) + return + } + + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined, + thinking, + demo: args.demo as RunDemo | undefined, + demoText: args.demoText, + }) } if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = attachSDK(directory) return await execute(sdk) } - await bootstrap(process.cwd(), async () => { + await bootstrap(directory ?? root, async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }, diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 000000000000..15a2278805a4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1302 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunDemo, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + mode: RunDemo + text?: string + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = (() => { + if (kind === "single") { + return [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + } + + if (kind === "checklist") { + return [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + } + + if (kind === "custom") { + return [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + } + + return [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + })() + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + if (input.mode === "on") { + return + } + + if (input.mode === "permission") { + emitPermission(state, "edit") + return + } + + if (input.mode === "question") { + emitQuestion(state, "multi") + return + } + + if (input.mode === "mix") { + await emitFmt(state, "mix", "") + return + } + + if (input.mode === "text") { + await emitFmt(state, "text", input.text ?? SAMPLE_MARKDOWN) + } + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 000000000000..741198a49959 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,183 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 000000000000..006b33b5aed1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,487 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolDiffView, toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +type RejectArea = { + isDestroyed: boolean + plainText: string + cursorOffset: number + setText(text: string): void + focus(): void +} + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: RejectArea | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +