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] 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 | 90 +++++++++++++------ .../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, 310 insertions(+), 294 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 ecacea83dc..8a92fdef66 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 9e4d13e0ad..3df083ef45 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 06ff4f4aa7..bd1ecdffd3 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 63e6ddf0fc..12cf4b1991 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 0c6fe6ec91..14cfa89117 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 5761077cbd..e5e19ff2e5 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 62960b2c47..729871e5fa 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 70082c8e2e..eda4175b9f 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 e8f3e6a11e..a5d4ad3ce2 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 618dea5188..20b0f0c823 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 1a9d907bb9..4f0d01aa3f 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 2599bcfc24..efb4cd2f9b 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 a5723e1d7a..d9d660b6a4 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 2851290f8a..e47cbfc7c3 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 2b0604f4ba..48c4d5f082 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 1ddd9cec32..f7a072c7df 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 65032de962..b03b755a60 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 c9a62c8645..08cdbcdb68 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 e8aab62d84..8d609e59b6 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 1e26676fc8..23f59bf8a4 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 21d9c0e9f2..e97a282851 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 365663eeef..e3b4dd940d 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 87fe422bc7..328ce2aaf2 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 9068df4778..4a8f1d3cfd 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 beff6755f9..19461bcfe7 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 1806e0eba6..b2113d2319 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 28ee4c4f26..06fb75799e 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 6cdf3f697c..4e5663bbdb 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 c97d4b8eec..8610c73f6a 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 3a07511b21..0000000000 --- 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 0ea57064cf..2808d99ee8 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 7b8dff37b7..cce46853d5 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 fe12aadb14..0000000000 --- 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 59e9b626ad..0000000000 --- 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 647605d599..8281f03686 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 0000000000..0b552045c5 --- /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 a09289235c..f73de28051 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` }) +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 + } +> - return Tool.define(opts.id, async () => { +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 a3ae01c5c1..08b394cfa6 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 712f9112ea..d8532c4215 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 0ac61aee71..fe7cf31913 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 b98f4a3ae0..57733cc043 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 0aee396f44..41530bd467 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 799bb3e2ae..0ada95be0c 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 1fa2e61eb2..07fc50ad23 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 3634d6fb7e..6cd79c9b72 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 0fc25c1a6b..0fc72316e5 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 95d90325ad..0a34285482 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 cbeaed3289..01801279cc 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 aedfacc3a2..2c587b96ae 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 0a9aa4358e..7beb941e41 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 207b400a7d..7c8a9e06d3 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 092c2c7e03..f2b028e9a8 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 1780c83ba9..308d2dbcba 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 e79e97a3ab..1a5c1efe1e 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 03349ce011..0331ba6862 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 038870d384..c2dadd98ae 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 3558fd9452..a45fed4c3c 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