Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
048ac63
refactor: split monolithic bash tool into separate bash/pwsh/powershe…
Hona Mar 30, 2026
67dfbcb
fix: use dynamic imports for tree-sitter and shell-aware metadata tags
Hona Mar 30, 2026
3e26c3a
refactor: extract shell tool factory to eliminate duplication
Hona Mar 30, 2026
51ebba2
refactor: add shell-specific guidance to each tool prompt
Hona Mar 30, 2026
48f9082
refactor: use positive tone in shell guidance prompts
Hona Mar 30, 2026
676519d
refactor: apply positive guidance and parameterize shell commands in …
Hona Mar 30, 2026
f21bf4a
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 3, 2026
95577c7
fix(config): preserve bash permission compatibility
Hona Apr 3, 2026
6ad6358
fix: render pwsh and powershell tools correctly in UI
Hona Apr 3, 2026
23e77fd
fix(shell): preserve powershell exit codes
Hona Apr 3, 2026
baf476f
test(shell): handle nullable exit metadata
Hona Apr 3, 2026
2eb9ae4
refactor(shell): centralize shell tool identity
Hona Apr 3, 2026
32ec366
fix(shell): keep shell config consistent
Hona Apr 3, 2026
2555117
fix(shell): avoid abort hangs and utf8 corruption
Hona Apr 3, 2026
39088e1
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 8, 2026
f1547de
ok
Hona Apr 8, 2026
ee0884a
fix(shell): preserve legacy bash compatibility
Hona Apr 8, 2026
f9a633b
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 23, 2026
b75f831
.
Hona Apr 23, 2026
3e30068
refactor: make shell the canonical tool internals
Hona Apr 23, 2026
6d66973
clean
Hona Apr 23, 2026
0d500a7
Create todo.spec.ts
Hona Apr 23, 2026
cffb8eb
.
Hona Apr 23, 2026
26d77ad
edges
Hona Apr 23, 2026
7266b48
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 23, 2026
4f8ff6a
.
Hona Apr 23, 2026
341b8e7
perms
Hona Apr 25, 2026
428b0c4
cmd
Hona Apr 25, 2026
f89955a
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 25, 2026
ecac4c4
split prompt/definition from logic
Hona Apr 25, 2026
790d181
slight accuracy
Hona Apr 25, 2026
2051cad
Update prompt.ts
Hona Apr 25, 2026
73ee7ae
Merge branch 'dev' into refactor-shells
Hona Apr 25, 2026
9dde86a
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 25, 2026
344dab3
Update next.test.ts
Hona Apr 25, 2026
5a7e69b
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 26, 2026
b1d9c57
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 27, 2026
ea277ba
css
Hona Apr 27, 2026
6ac33dd
test: update experimental api shell assertions
Hona Apr 27, 2026
9d9830b
no breaking changes
Hona Apr 28, 2026
70ca572
noooooooooo brekaing changes
Hona Apr 28, 2026
c16a0e0
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona Apr 28, 2026
d5ebfad
.
Hona Apr 28, 2026
f868719
.
Hona Apr 28, 2026
20c3461
f
Hona Apr 29, 2026
529a6ed
.
Hona Apr 29, 2026
9e0379f
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona May 2, 2026
ac5c1e0
.
Hona May 2, 2026
def5030
Merge remote-tracking branch 'upstream/dev' into refactor-shells
Hona May 2, 2026
7b5c333
javascript
Hona May 2, 2026
eb97cf5
lol
Hona May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof language.t>[0])
if (value === key) return ""
if (value === key) return fallback === key ? "" : language.t(fallback as Parameters<typeof language.t>[0])
return value
}

Expand Down
37 changes: 19 additions & 18 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { InstallationVersion } from "@/installation/version"
import { ShellToolID } from "@/tool/shell/id"

type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
Expand Down Expand Up @@ -144,7 +145,7 @@ export class Agent implements ACPAgent {
private sessionManager: ACPSessionManager
private eventAbort = new AbortController()
private eventStarted = false
private bashSnapshots = new Map<string, string>()
private shellSnapshots = new Map<string, string>()
private toolStarts = new Set<string>()
private permissionQueues = new Map<string, Promise<void>>()
private permissionOptions: PermissionOption[] = [
Expand Down Expand Up @@ -283,16 +284,16 @@ export class Agent implements ACPAgent {

switch (part.state.status) {
case "pending":
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
return

case "running":
const output = this.bashOutput(part)
const output = this.shellOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = Hash.fast(output)
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
if (ShellToolID.normalize(part.tool) === ShellToolID.id) {
if (this.shellSnapshots.get(part.callID) === hash) {
await this.connection
.sessionUpdate({
sessionId,
Expand All @@ -311,7 +312,7 @@ export class Agent implements ACPAgent {
})
return
}
this.bashSnapshots.set(part.callID, hash)
this.shellSnapshots.set(part.callID, hash)
}
content.push({
type: "content",
Expand Down Expand Up @@ -342,7 +343,7 @@ export class Agent implements ACPAgent {

case "completed": {
this.toolStarts.delete(part.callID)
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
Expand Down Expand Up @@ -423,7 +424,7 @@ export class Agent implements ACPAgent {
}
case "error":
this.toolStarts.delete(part.callID)
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
Expand Down Expand Up @@ -837,10 +838,10 @@ export class Agent implements ACPAgent {
await this.toolStart(sessionId, part)
switch (part.state.status) {
case "pending":
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
break
case "running":
const output = this.bashOutput(part)
const output = this.shellOutput(part)
const runningContent: ToolCallContent[] = []
if (output) {
runningContent.push({
Expand Down Expand Up @@ -871,7 +872,7 @@ export class Agent implements ACPAgent {
break
case "completed":
this.toolStarts.delete(part.callID)
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
Expand Down Expand Up @@ -951,7 +952,7 @@ export class Agent implements ACPAgent {
break
case "error":
this.toolStarts.delete(part.callID)
this.bashSnapshots.delete(part.callID)
this.shellSnapshots.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
Expand Down Expand Up @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent {
}
}

private bashOutput(part: ToolPart) {
if (part.tool !== "bash") return
private shellOutput(part: ToolPart) {
if (ShellToolID.normalize(part.tool) !== ShellToolID.id) return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
const output = part.state.metadata["output"]
if (typeof output !== "string") return
Expand Down Expand Up @@ -1549,9 +1550,9 @@ export class Agent implements ACPAgent {

function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
if (ShellToolID.normalize(tool) === ShellToolID.id) return "execute"

switch (tool) {
case "bash":
return "execute"
case "webfetch":
return "fetch"

Expand All @@ -1576,6 +1577,8 @@ function toToolKind(toolName: string): ToolKind {

function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
const tool = toolName.toLocaleLowerCase()
if (ShellToolID.normalize(tool) === ShellToolID.id) return []

switch (tool) {
case "read":
case "edit":
Expand All @@ -1584,8 +1587,6 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
default:
return []
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export const layer = Layer.effect(
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
shell: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/agent/prompt/explore.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/opencode/src/agent/prompt/title.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Your output must be:
<rules>
- 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
Expand Down
16 changes: 13 additions & 3 deletions packages/opencode/src/cli/cmd/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import fs from "fs/promises"
import { Filesystem } from "../../util"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { ShellToolID } from "../../tool/shell/id"
import { EOL } from "os"
import type { Argv } from "yargs"

type AgentMode = "all" | "primary" | "subagent"

const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]
const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]

const AgentCreateCommand = cmd({
command: "create",
Expand Down Expand Up @@ -123,7 +123,17 @@ const AgentCreateCommand = cmd({
// Select tools
let selectedTools: string[]
if (cliTools !== undefined) {
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
selectedTools = cliTools
? [
...new Set(
cliTools
.split(",")
.map((t) => t.trim())
.map(ShellToolID.normalize)
.filter(Boolean),
),
]
: AVAILABLE_TOOLS
} else {
const result = await prompts.multiselect({
message: "Select tools to enable (Space to toggle)",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,7 @@ export const GithubRunCommand = cmd({
function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
shell: ["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],
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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/bash"
import { ShellTool } from "../../tool/shell"
import { ShellToolID } from "../../tool/shell/id"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util"
import { AppRuntime } from "@/effect/app-runtime"
Expand Down Expand Up @@ -183,7 +184,7 @@ function skill(info: ToolProps<typeof SkillTool>) {
})
}

function bash(info: ToolProps<typeof BashTool>) {
function shell(info: ToolProps<typeof ShellTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
Expand Down Expand Up @@ -408,7 +409,7 @@ export const RunCommand = cmd({
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (ShellToolID.normalize(part.tool) === ShellToolID.id) return shell(props<typeof ShellTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import { Locale } from "@/util"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash"
import { ShellTool } from "@/tool/shell"
import { ShellToolID } from "@/tool/shell/id"
import type { GlobTool } from "@/tool/glob"
import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
Expand Down Expand Up @@ -1550,8 +1551,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
return (
<Show when={!shouldHide()}>
<Switch>
<Match when={props.part.tool === "bash"}>
<Bash {...toolprops} />
<Match when={ShellToolID.normalize(props.part.tool) === ShellToolID.id}>
<Shell {...toolprops} />
</Match>
<Match when={props.part.tool === "glob"}>
<Glob {...toolprops} />
Expand Down Expand Up @@ -1785,7 +1786,7 @@ function BlockTool(props: {
)
}

function Bash(props: ToolProps<typeof BashTool>) {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const sync = useSync()
const isRunning = createMemo(() => props.part.state.status === "running")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util"
import { Locale } from "@/util"
import { Global } from "@/global"
import { ShellToolID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
Expand Down Expand Up @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}

if (permission === "bash") {
if (ShellToolID.normalize(permission) === ShellToolID.id) {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { ConfigPermission } from "./permission"
import { ShellToolID } from "@/tool/shell/id"

const log = Log.create({ service: "config" })

Expand Down Expand Up @@ -86,10 +87,14 @@ const normalize = (agent: z.infer<typeof Info>) => {
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch") {
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
if (ShellToolID.normalize(tool) === ShellToolID.id) {
permission.shell = action
continue
}
permission[tool] = action
}
globalThis.Object.assign(permission, agent.permission)
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
import { ShellToolID } from "@/tool/shell/id"

const log = Log.create({ service: "config" })

Expand Down Expand Up @@ -661,10 +662,14 @@ export const layer = Layer.effect(
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch") {
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
if (ShellToolID.normalize(tool) === ShellToolID.id) {
perms.shell = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const InputObject = Schema.StructWithRest(
glob: Schema.optional(Rule),
grep: Schema.optional(Rule),
list: Schema.optional(Rule),
bash: Schema.optional(Rule),
shell: Schema.optional(Rule),
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/permission/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Wildcard } from "@/util"
import { ShellToolID } from "@/tool/shell/id"

type Rule = {
permission: string
Expand All @@ -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: "*" }
}
Loading
Loading