diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 9a8384118..ec0f0e19f 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -126,6 +126,9 @@ export type AgentToolsetArgs = { settings?: Settings | (() => Settings | undefined); catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]); profiles?: AgentProfile[] | (() => AgentProfile[]); + // Opt-in: dispatch each sub-agent into its own git worktree instead of + // sharing this session's cwd. See src/subagent/worktree.ts. + useWorktree?: boolean; }; }; @@ -229,6 +232,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { }); }); -// A sub-agent runs in its own git worktree, so its requests carry that -// worktree as cwd while the gate's restriction closure stays anchored to the -// session cwd that built it. The same relative path resolves differently -// against the two anchors, so coverage must use the gate's anchor: otherwise a -// path evaluate() called restricted reads as unrestricted at reconciliation -// time and a broad grant drains it without ever prompting. -describe("grant coverage anchors path restriction to the gate, not the request", () => { +// Relative path tokens rebind to the request's process cwd before the gate's +// restriction closure judges them, so a sub-agent worktree's relative targets +// match what the shell will open. Absolute paths still pass through the +// session-anchored restriction (workspace + registered worktree roots). +describe("grant coverage rebinds relative paths to the request process cwd", () => { const root = mkdtempSync(join(tmpdir(), "gate-anchor-")); const sessionCwd = join(root, "main"); const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" }); @@ -103,7 +101,7 @@ describe("grant coverage anchors path restriction to the gate, not the request", git(["init", "-q"], sessionCwd); git(["config", "user.email", "t@example.com"], sessionCwd); git(["config", "user.name", "t"], sessionCwd); - writeFileSync(join(sessionCwd, "outside-file"), "secret\n"); + writeFileSync(join(sessionCwd, "seed.txt"), "seed\n"); git(["add", "."], sessionCwd); git(["commit", "-qm", "seed"], sessionCwd); const agentCwd = join(sessionCwd, "agent-x"); @@ -114,15 +112,29 @@ describe("grant coverage anchors path restriction to the gate, not the request", createWorktreeRootsProvider(sessionCwd), ).isRestricted; - test("a sub-agent request reaching outside its worktree stays uncovered", () => { + test("a relative path that lands outside the workspace stays uncovered", () => { + // agent-x → ../../escape is outside root/main (and outside any worktree root). const request: PermissionRequest = { tool: "run_shell", action: "Run", - subject: "cat ../outside-file", + subject: "cat ../../escape", scopes: [], cwd: agentCwd, }; const grant: Approval = { tool: "run_shell", pattern: "cat *" }; expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(false); }); + + test("a relative path inside the registered worktree is not forced-restricted", () => { + writeFileSync(join(agentCwd, "local.txt"), "ok\n"); + const request: PermissionRequest = { + tool: "run_shell", + action: "Run", + subject: "cat local.txt", + scopes: [], + cwd: agentCwd, + }; + const grant: Approval = { tool: "run_shell", pattern: "cat *" }; + expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(true); + }); }); diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 580416441..5c5dbfed8 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -1,4 +1,5 @@ import type { ToolCall } from "@intx/types/runtime"; +import { isAbsolute, resolve } from "node:path"; import type { Approval, ApprovalOutcome, GrantScope, PermissionRequest, RequestApproval } from "./types.js"; import { classifyTool, @@ -75,6 +76,23 @@ function segmentGuard(segment: string, isRestricted: (path: string, isWrite: boo return undefined; } +// Relative path tokens in a shell command resolve against the process cwd of the +// agent that issued the call — not the session cwd that built the gate. Absolute +// paths pass through unchanged so createPathRestriction still judges them against +// the session workspace + registered worktree roots. Without this rebinding, a +// sub-agent in an isolated worktree would have `cat secrets.txt` auto-allowed or +// restriction-checked as if it opened `$SESSION/secrets.txt` while the shell +// actually opened `$WORKTREE/secrets.txt`. +function bindRestrictedToProcessCwd( + isRestricted: (path: string, isWrite: boolean) => boolean, + processCwd: string, +): (path: string, isWrite: boolean) => boolean { + return (path, isWrite) => { + const anchored = isAbsolute(path) ? path : resolve(processCwd, path); + return isRestricted(anchored, isWrite); + }; +} + // Every guard a run_shell request must clear BEFORE it is ever matched // against a grant — hard-deny and forced-ask checks that no grant, however // broad, is allowed to bypass. This is the single place that sequence is @@ -94,8 +112,12 @@ export function preGrantGuardReason( if (segments.length === 0) return "empty command"; const blockReason = runShellAuthzBlockReason(fullCommand); if (blockReason !== undefined) return blockReason; + // Prefer the request's process cwd when present so reconciliation uses the + // same relative-path anchor evaluate() used when the prompt was raised. + const restricted = + request.cwd !== undefined ? bindRestrictedToProcessCwd(isRestricted, request.cwd) : isRestricted; for (const segment of segments) { - const guard = segmentGuard(segment, isRestricted); + const guard = segmentGuard(segment, restricted); if (guard !== undefined) { return guard.kind === "secret" ? `${segment} references a sensitive path` @@ -290,10 +312,17 @@ export function createPermissionGate(options: PermissionGateOptions): Permission const evaluate = async (call: ToolCall): Promise => { if (skipPermissions) return { allowed: true }; + // Sub-agent tool calls run under ALS identity (identity-context.ts). The + // process cwd is the worktree (or session when no identity is set); every + // relative-path judgment below must use it so auto-allow and restriction + // match what the shell will open. + const subAgentIdentity = getSubAgentIdentity(); + const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; + const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd); // A call targeting a restricted path (outside the workspace, or a write // under .agent-state) drops from allow to ask, so it never auto-allows on // tier or shell-safety below. - const restricted = callTargetsRestricted(call, isRestricted); + const restricted = callTargetsRestricted(call, isRestrictedHere); const shellCmd = call.name === "run_shell" && typeof call.arguments.command === "string" ? call.arguments.command @@ -307,7 +336,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission if (!restricted && classifyTool(call.name, mcpTiers) === "allow") { return { allowed: true }; } - if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, cwd)) { + if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, effectiveCwd)) { return { allowed: true }; } if (auto) { @@ -317,7 +346,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // operator prompt. Everything else auto-allows. Path-keyed secret // reads stay hard-denied by secret-guard; shell that only *mentions* // a secret path is ask so an explicit one-time approval can pass it. - const shellRule = autoShellRuleForCall(call, isRestricted); + const shellRule = autoShellRuleForCall(call, isRestrictedHere); if (shellRule?.effect === "deny") return { allowed: false, reason: shellRule.reason }; if (shellRule === undefined) return { allowed: true }; } else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) { @@ -327,11 +356,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // blanket-allowed; fall through to the operator prompt below. } - // A sub-agent's own tool calls run under its identity in ALS (see - // identity-context.ts, wired from subagent/run.ts). When present, the - // prompt is attributed to that sub-agent instead of the top-level session. - const subAgentIdentity = getSubAgentIdentity(); - const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; + // When present, the prompt is attributed to that sub-agent instead of the + // top-level session. for (const rawRequest of buildRequests(call)) { const request: typeof rawRequest = { ...rawRequest, @@ -373,7 +399,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // restriction check below for the same rule applied within a chain. if ( !fullReferencesSecret && - !commandTargetsRestricted(fullCommand, isRestricted) && + !commandTargetsRestricted(fullCommand, isRestrictedHere) && segments.length > 1 && hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, effectiveCwd) ) { @@ -389,7 +415,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // replay for a guarded one just because the pattern also matches it. // segmentGuard is the same guard preGrantGuardReason applies before // isRequestCoveredByGrant lets a queued request skip the prompt. - const guard = segmentGuard(segment, isRestricted); + const guard = segmentGuard(segment, isRestrictedHere); if (guard !== undefined) { if (guard.kind === "secret") anySecret = true; needsOperator = true; @@ -399,7 +425,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission continue; } // Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip. - if (isAutoAllowedShellSegment(segment, cwd)) { + // Containment is judged against the process cwd, not the session cwd. + if (isAutoAllowedShellSegment(segment, effectiveCwd)) { continue; } needsOperator = true; diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index c6cef7834..7a4872fe1 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -2838,3 +2838,69 @@ describe("sub-agent identity on permission requests", () => { expect(withB?.cwd).toBe("/repo-b"); }); }); + +describe("sub-agent auto-allow uses the process cwd, not the session cwd", () => { + test("a relative read inside the worktree auto-allows under the process cwd", async () => { + // Nested worktree under the session so absolute paths stay inside the + // workspace roots. Auto-allow must still judge containment against the + // worktree (process cwd), not the session — this case is the happy path + // where the relative target lands inside the worktree either way. + const root = mkdtempSync(join(tmpdir(), "gate-eff-cwd-")); + const sessionCwd = join(root, "session"); + const agentCwd = join(sessionCwd, "agent-x"); + mkdirSync(sessionCwd); + mkdirSync(agentCwd); + writeFileSync(join(agentCwd, "local.txt"), "worktree-local\n"); + + let prompted = false; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + prompted = true; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: sessionCwd, + }); + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const verdict = await runWithSubAgentIdentity( + { description: "Worktree worker", cwd: agentCwd }, + () => gate.evaluate(shellCall("cat local.txt")), + ); + expect(verdict).toEqual({ allowed: true }); + expect(prompted).toBe(false); + }); + + test("a relative read that escapes the worktree but not the session is not auto-allowed", async () => { + // Worktree nested under the session: `cat ../session-only.txt` resolves + // inside the session when judged against session cwd (bug → auto-allow) + // but escapes the worktree when judged against the process cwd (correct → + // ask). + const root = mkdtempSync(join(tmpdir(), "gate-escape-wt-")); + const sessionCwd = join(root, "session"); + mkdirSync(sessionCwd); + writeFileSync(join(sessionCwd, "session-only.txt"), "only-in-session\n"); + const agentCwd = join(sessionCwd, "agent-x"); + mkdirSync(agentCwd); + + let prompted = false; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + prompted = true; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: sessionCwd, + }); + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const verdict = await runWithSubAgentIdentity( + { description: "Nested worktree", cwd: agentCwd }, + () => gate.evaluate(shellCall("cat ../session-only.txt")), + ); + expect(verdict).toEqual({ allowed: true }); + expect(prompted).toBe(true); + }); +}); diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 44f373be7..7228d3507 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -112,3 +112,12 @@ export { taskToolDefinition, type TaskToolDeps, } from "./task-tool.js"; + +export { + cleanupSubAgentWorktree, + createSubAgentWorktree, + WorktreeError, + type SubAgentWorktree, + type WorktreeCleanupResult, + type WorktreeExec, +} from "./worktree.js"; diff --git a/src/subagent/run.ts b/src/subagent/run.ts index db5dd7a8e..1eb8a537c 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -205,11 +205,12 @@ export function createSubAgentRunController( }; } -// Spin up an isolated, autonomous agent loop against the same working tree, -// hand it one task, and return its final report. The sub-agent shares the -// dispatcher's cwd so its edits land in the real repo, but gets its own posix -// tool instances and its own git-backed context store so the two loops never -// trample each other's state. +// Spin up an isolated, autonomous agent loop, hand it one task, and return +// its final report. `params.cwd` is either the dispatcher's own cwd (shared +// mode) or a worktree snapshotted from the dispatcher's last commit +// (isolated mode, see task-tool.ts's useWorktree) — either way this loop +// gets its own posix tool instances and its own git-backed context store so +// the two loops never trample each other's state. export async function runSubAgent(params: RunSubAgentParams): Promise { return withSubAgentSlot(() => runSubAgentInner(params), { reentrant: params.nested === true, @@ -338,6 +339,7 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise { ...(nd.catalog !== undefined ? { catalog: nd.catalog } : {}), ...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}), ...(nd.parentSessionId !== undefined ? { parentSessionId: nd.parentSessionId } : {}), + ...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}), }), ...(nd.profiles !== undefined ? [ diff --git a/src/subagent/task-tool-worktree.test.ts b/src/subagent/task-tool-worktree.test.ts new file mode 100644 index 000000000..b76dd1e26 --- /dev/null +++ b/src/subagent/task-tool-worktree.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { createTaskTool } from "./task-tool.js"; +import type { RunSubAgentParams } from "./types.js"; +import { createPermissionGate } from "../permission/gate.js"; + +const run = promisify(execFile); + +const testPermissionGate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, +}); + +const provider = { + providerName: "test-provider", + baseURL: "http://localhost", + model: "test-model", +}; + +async function callTask( + tool: ReturnType, + args: Record, +): Promise { + if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); + const result = await tool.handler( + { id: "call-1", name: "task", arguments: args }, + new AbortController().signal, + ); + return typeof result.content === "string" ? result.content : JSON.stringify(result.content); +} + +async function makeRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "corbits-worktree-")); + await run("git", ["init"], { cwd: dir }); + await run("git", ["config", "user.email", "t@t.test"], { cwd: dir }); + await run("git", ["config", "user.name", "t"], { cwd: dir }); + await writeFile(join(dir, "seed.txt"), "seed"); + await run("git", ["add", "."], { cwd: dir }); + await run("git", ["commit", "-m", "seed"], { cwd: dir }); + return dir; +} + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop()!; + await rm(dir, { recursive: true, force: true }); + } +}); + +describe("createTaskTool worktree isolation", () => { + test("propagates a fresh worktree path as the sub-agent's cwd, cleaned up when unchanged", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let captured: RunSubAgentParams | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + captured = params; + return "done"; + }, + }); + + const result = await callTask(tool, { description: "Isolated job", prompt: "Do the work" }); + + expect(result).toContain("done"); + expect(captured?.cwd).toBeDefined(); + expect(captured?.cwd).not.toBe(repo); + expect(captured?.cwd?.startsWith(workdirBase)).toBe(true); + + // Unchanged worktree is removed automatically: `git worktree list` no + // longer reports it as a registered worktree of the repo. + const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); + expect(stdout).not.toContain(captured!.cwd); + }); + + test("shares the dispatcher cwd when worktree isolation is not requested", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let captured: RunSubAgentParams | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + run: async (params) => { + captured = params; + return "done"; + }, + }); + + await callTask(tool, { description: "Shared job", prompt: "Do the work" }); + + expect(captured?.cwd).toBe(repo); + }); + + test("fails closed and never dispatches when the dispatcher cwd is not a git repository", async () => { + const notARepo = await mkdtemp(join(tmpdir(), "corbits-not-a-repo-")); + tempDirs.push(notARepo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let ran = false; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: notARepo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async () => { + ran = true; + return "done"; + }, + }); + + const result = await callTask(tool, { description: "Blocked job", prompt: "Do the work" }); + + expect(result).toContain("Error:"); + expect(result).toContain("not inside a git repository"); + expect(ran).toBe(false); + }); + + test("preserves a worktree the sub-agent left dirty, with a notice in the report", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let worktreePath: string | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + worktreePath = params.cwd; + // Simulate the sub-agent leaving uncommitted work behind. + await writeFile(join(params.cwd, "new-file.txt"), "unfinished work"); + return "done"; + }, + }); + + const result = await callTask(tool, { description: "Dirty job", prompt: "Do the work" }); + + expect(result).toContain("done"); + expect(result).toContain("uncommitted changes and was left in place"); + expect(worktreePath).toBeDefined(); + const contents = await readFile(join(worktreePath!, "new-file.txt"), "utf8"); + expect(contents).toBe("unfinished work"); + + const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); + expect(stdout).toContain(worktreePath!); + }); + + test("preserves a worktree the sub-agent left stashed, with a notice naming the stash", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let worktreePath: string | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + worktreePath = params.cwd; + // Simulate the sub-agent stashing mid-task: `git status` reports + // clean afterward even though the work is not actually gone — it is + // parked in the repo's shared refs/stash. + await writeFile(join(params.cwd, "wip.txt"), "half-finished change"); + await run("git", ["add", "."], { cwd: params.cwd }); + await run("git", ["stash"], { cwd: params.cwd }); + return "done"; + }, + }); + + const result = await callTask(tool, { description: "Stashing job", prompt: "Do the work" }); + + expect(result).toContain("done"); + expect(result).toContain("stash"); + expect(worktreePath).toBeDefined(); + + // The worktree itself is preserved rather than silently removed — + // `git status` alone would have called this clean. + const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); + expect(stdout).toContain(worktreePath!); + + // The stash entry the sub-agent created is still recoverable. + const { stdout: stashList } = await run("git", ["stash", "list"], { cwd: repo }); + expect(stashList).toContain("stash@{0}"); + }); +}); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 4bc6d1abd..5c8ad50bf 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -36,6 +36,9 @@ import { TURN_BUDGET_STOP_AFTER_DISPATCHES, } from "./brief-dispatch.js"; import { isSubAgentCancelError } from "./dispose.js"; +import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "./worktree.js"; +import { generateSessionId } from "../session/index.js"; +import { join } from "node:path"; import type { NestedDispatchDeps, RunSubAgentParams, @@ -61,7 +64,7 @@ export const TaskToolArgs = type({ export const taskToolDefinition: ToolDefinition = { name: "task", description: - "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions and shares your working tree. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns or tier alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", + "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns or tier alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", inputSchema: { type: "object", properties: { @@ -163,6 +166,13 @@ export type TaskToolDeps = SubAgentSandboxDeps & { * outer tool-execution watchdog so a salvage report can return first. */ deadlineMs?: number; + /** + * Opt-in: isolate each spawn in its own git worktree branched from the + * dispatcher's HEAD instead of sharing deps.cwd. Fails closed (see + * worktree.ts) when deps.cwd is not a git repository or worktree creation + * fails. Omit (default) to keep today's shared-cwd dispatch. + */ + useWorktree?: boolean; }; function taskToolResult(callId: string, content: string): ToolResult { @@ -458,6 +468,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ...(deps.catalog !== undefined ? { catalog: deps.catalog } : {}), ...(deps.profiles !== undefined ? { profiles: deps.profiles } : {}), ...(session !== undefined ? { parentSessionId: session.id } : {}), + ...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}), } : undefined; // Per-spawn controller so strip cancel and parent stop share one abort @@ -478,10 +489,49 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { }); } + let worktreeCwd: string | undefined; + let worktreeStashBaseline: readonly string[] | null = []; + let worktreeHeadAtCreate: string | undefined; + if (deps.useWorktree === true) { + const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId()); + try { + const worktree = await createSubAgentWorktree(deps.cwd, worktreePath); + worktreeCwd = worktree.path; + worktreeStashBaseline = worktree.stashBaseline; + worktreeHeadAtCreate = worktree.headAtCreate; + } catch (err) { + // Admit already happened and the strip session may be "running" — + // release the ledger slot and fail the session so a worktree setup + // error never burns turn-budget budget or leaves a ghost row. + const message = + err instanceof WorktreeError + ? err.message + : `sub-agent worktree setup failed: ${err instanceof Error ? err.message : String(err)}`; + briefLedger.release(fingerprint); + if (session !== undefined) deps.sessions?.fail(session.id, message); + signal.removeEventListener("abort", onParentAbort); + return taskToolResult(call.id, `Error: ${message}`); + } + } + // Cleanup runs once the sub-agent's report is ready, regardless of + // outcome, so a cancelled or failed run's worktree is still reclaimed + // (or preserved with a notice) rather than leaked. + const finishWithWorktree = async (result: ToolResult): Promise => { + if (worktreeCwd === undefined) return result; + const cleanup = await cleanupSubAgentWorktree(deps.cwd, worktreeCwd, { + stashBaseline: worktreeStashBaseline, + ...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}), + }); + if (cleanup.status === "preserved") { + return { ...result, content: `${result.content}\n\n${cleanup.notice}` }; + } + return result; + }; + try { const params: RunSubAgentParams = { ...sandbox, - cwd: deps.cwd, + cwd: worktreeCwd ?? deps.cwd, workdirBase: deps.getWorkdirBase(), provider, ...(tier !== undefined ? { tier } : {}), @@ -530,15 +580,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); } const reported = appendSubAgentParentHints(result, hintOptions); - return taskToolResult( - call.id, - `Sub-agent "${description}" reported:\n\n${reported}`, + return finishWithWorktree( + taskToolResult(call.id, `Sub-agent "${description}" reported:\n\n${reported}`), ); } if (session !== undefined) deps.sessions?.complete(session.id, result); const reported = appendSubAgentParentHints(result, hintOptions); - return taskToolResult(call.id, `Sub-agent "${description}" reported:\n\n${reported}`); - + return finishWithWorktree( + taskToolResult(call.id, `Sub-agent "${description}" reported:\n\n${reported}`), + ); } catch (err) { if ( @@ -553,7 +603,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ) { deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); } - return taskToolResult(call.id, cancelledSubAgentMessage(description)); + return finishWithWorktree(taskToolResult(call.id, cancelledSubAgentMessage(description))); } // Run never produced a body — undo the admit so turn-budget retry budget // is not burned by auth/provider crashes. @@ -567,7 +617,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { // fail() prefixes "Error:" on the transcript report entry — pass bare text. const failReason = authMessage ?? sessionError; if (session !== undefined) deps.sessions?.fail(session.id, failReason); - return taskToolResult(call.id, message); + return finishWithWorktree(taskToolResult(call.id, message)); } finally { signal.removeEventListener("abort", onParentAbort); } diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 79338c1f0..529c00c00 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -60,6 +60,9 @@ export type NestedDispatchDeps = SubAgentSandboxDeps & { // The orchestrator's own session id, so workers it dispatches record as // nested (one-hop) sessions the Agents strip can indent under it. parentSessionId?: string; + // Forwarded from the outer TaskToolDeps so nested workers get the same + // worktree-isolation behavior as their orchestrator. + useWorktree?: boolean; }; /** Typed spawn intent — optional on `task`; omit Intent section when unset. */ diff --git a/src/subagent/worktree.test.ts b/src/subagent/worktree.test.ts new file mode 100644 index 000000000..1e03b6e31 --- /dev/null +++ b/src/subagent/worktree.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from "bun:test"; + +import { + cleanupSubAgentWorktree, + createSubAgentWorktree, + WorktreeError, + type WorktreeExec, +} from "./worktree.js"; + +function recordingExec( + responses: Record, +): { exec: WorktreeExec; calls: string[][] } { + const calls: string[][] = []; + const exec: WorktreeExec = async (args) => { + calls.push(args); + // Prefer a two-arg key so `rev-parse --show-toplevel` and `rev-parse HEAD` + // can return different fixtures; fall back to the verb alone. + const key2 = args.slice(0, 2).join(" "); + const key1 = args[0]!; + const response = responses[key2] ?? responses[key1]; + if (response?.error !== undefined) throw response.error; + return { stdout: response?.stdout ?? "", stderr: "" }; + }; + return { exec, calls }; +} + +describe("createSubAgentWorktree", () => { + test("creates a detached worktree at HEAD when repoCwd is a git repo", async () => { + const { exec, calls } = recordingExec({ + "rev-parse --show-toplevel": { stdout: "/repo\n" }, + worktree: { stdout: "" }, + "rev-parse HEAD": { stdout: "abc123def456\n" }, + stash: { stdout: "" }, + }); + const result = await createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result.path).toBe("/repo/.worktrees/abc"); + expect(result.stashBaseline).toEqual([]); + expect(result.headAtCreate).toBe("abc123def456"); + expect(calls).toEqual([ + ["rev-parse", "--show-toplevel"], + ["worktree", "add", "--detach", "/repo/.worktrees/abc", "HEAD"], + ["rev-parse", "HEAD"], + ["stash", "list"], + ]); + }); + + test("captures the current stash list as a baseline", async () => { + const { exec } = recordingExec({ + "rev-parse --show-toplevel": { stdout: "/repo\n" }, + worktree: { stdout: "" }, + "rev-parse HEAD": { stdout: "abc123\n" }, + stash: { stdout: "stash@{0}: WIP on main: abc1234 pre-existing stash\n" }, + }); + const result = await createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result.stashBaseline).toEqual(["stash@{0}: WIP on main: abc1234 pre-existing stash"]); + }); + + test("records a null stash baseline when stash list fails at create", async () => { + const { exec } = recordingExec({ + "rev-parse --show-toplevel": { stdout: "/repo\n" }, + worktree: { stdout: "" }, + "rev-parse HEAD": { stdout: "abc123\n" }, + stash: { error: new Error("stash failed") }, + }); + const result = await createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result.stashBaseline).toBeNull(); + }); + + test("fails closed when repoCwd is not a git repository", async () => { + const { exec } = recordingExec({ + "rev-parse --show-toplevel": { error: new Error("not a git repository") }, + }); + await expect(createSubAgentWorktree("/not-a-repo", "/tmp/wt", exec)).rejects.toThrow( + WorktreeError, + ); + }); + + test("fails closed when worktree add fails", async () => { + const { exec } = recordingExec({ + "rev-parse --show-toplevel": { stdout: "/repo\n" }, + worktree: { error: new Error("worktree already exists") }, + }); + await expect(createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec)).rejects.toThrow( + WorktreeError, + ); + }); +}); + +describe("cleanupSubAgentWorktree", () => { + test("removes a clean worktree with no new stash entries", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + stash: { stdout: "" }, + worktree: { stdout: "" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); + expect(calls).toEqual([ + ["status", "--porcelain", "--ignored"], + ["stash", "list"], + ["worktree", "remove", "/repo/.worktrees/abc"], + ]); + }); + + test("preserves a worktree containing only gitignored output", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "!! dist/output.txt\n" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("uncommitted changes"); + } + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("preserves a dirty worktree instead of removing it", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: " M src/index.ts\n" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + expect(result).toMatchObject({ path: "/repo/.worktrees/abc" }); + if (result.status === "preserved") { + expect(result.notice).toContain("uncommitted changes"); + } + // Never runs `worktree remove` against a dirty tree. + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("preserves the worktree when status cannot be checked", async () => { + const { exec } = recordingExec({ + status: { error: new Error("no such directory") }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + }); + + test("preserves the worktree when removal fails", async () => { + const { exec } = recordingExec({ + status: { stdout: "" }, + stash: { stdout: "" }, + worktree: { error: new Error("worktree is locked") }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("could not be removed automatically"); + } + }); + + test("preserves a clean worktree that created a new stash entry", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + stash: { stdout: "stash@{0}: WIP on (no branch): abc1234 sub-agent work\n" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("stash entry"); + expect(result.notice).toContain("stash@{0}"); + } + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("does not flag a stash entry that predates this worktree", async () => { + const preexisting = "stash@{0}: WIP on main: abc1234 unrelated older stash"; + const { exec } = recordingExec({ + status: { stdout: "" }, + stash: { stdout: `${preexisting}\n` }, + worktree: { stdout: "" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [preexisting] }, + exec, + ); + expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); + }); + + test("preserves when stash list fails at cleanup", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + stash: { error: new Error("stash list failed") }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [] }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("could not inspect the stash list"); + } + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("preserves when stash baseline was unknown at create", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: null }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("stash baseline could not be recorded"); + } + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("preserves when HEAD advanced on a clean detached worktree", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + "rev-parse HEAD": { stdout: "newcommit99\n" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [], headAtCreate: "oldcommit00" }, + exec, + ); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("HEAD advanced"); + } + expect(calls.some((call) => call[0] === "worktree")).toBe(false); + }); + + test("removes when HEAD is unchanged and the tree is clean", async () => { + const { exec } = recordingExec({ + status: { stdout: "" }, + "rev-parse HEAD": { stdout: "samehead\n" }, + stash: { stdout: "" }, + worktree: { stdout: "" }, + }); + const result = await cleanupSubAgentWorktree( + "/repo", + "/repo/.worktrees/abc", + { stashBaseline: [], headAtCreate: "samehead" }, + exec, + ); + expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); + }); +}); diff --git a/src/subagent/worktree.ts b/src/subagent/worktree.ts new file mode 100644 index 000000000..2fe68a2ab --- /dev/null +++ b/src/subagent/worktree.ts @@ -0,0 +1,194 @@ +/** + * Git worktree lifecycle for isolated sub-agent dispatch: create a fresh + * worktree from the dispatcher's HEAD, and remove it again once the + * sub-agent finishes, unless it left uncommitted changes, new commits, or + * stash entries behind. + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type WorktreeExec = ( + args: string[], + options: { cwd: string }, +) => Promise<{ stdout: string; stderr: string }>; + +const defaultExec: WorktreeExec = (args, options) => execFileAsync("git", args, options); + +export class WorktreeError extends Error {} + +export type SubAgentWorktree = { + path: string; + // The repo's `git stash list` output at the moment this worktree was + // created (see stashList). Stash refs live on the shared repo, not the + // worktree, so cleanup diffs against this baseline to notice stash entries + // the sub-agent created while it ran — see cleanupSubAgentWorktree. + // `null` means the baseline could not be read: cleanup must preserve rather + // than risk removing a worktree that may have stashed. + stashBaseline: string[] | null; + // `git rev-parse HEAD` at create time. Detached-HEAD commits leave a clean + // porcelain status but move HEAD — cleanup preserves when HEAD advanced so + // those commits are not left reflog-only after `worktree remove`. + headAtCreate: string; +}; + +// The repo's stash list as an array of "stash@{N}: " lines, or null +// when the lookup itself fails. A failed lookup must never make cleanup more +// willing to remove a worktree, so callers treat null as "unknown → preserve". +async function stashList(repoCwd: string, exec: WorktreeExec): Promise { + try { + const { stdout } = await exec(["stash", "list"], { cwd: repoCwd }); + return stdout.split("\n").filter((line) => line.trim().length > 0); + } catch { + return null; + } +} + +// Creates a fresh git worktree at `path`, detached at the current HEAD of +// `repoCwd`. Fails closed: `repoCwd` must be inside a git working tree and +// `git worktree add` must succeed, or this throws WorktreeError with a +// message safe to surface directly to the operator. +export async function createSubAgentWorktree( + repoCwd: string, + path: string, + exec: WorktreeExec = defaultExec, +): Promise { + try { + await exec(["rev-parse", "--show-toplevel"], { cwd: repoCwd }); + } catch (err) { + throw new WorktreeError( + `Cannot create an isolated sub-agent worktree: "${repoCwd}" is not inside a git repository.`, + { cause: err }, + ); + } + try { + await exec(["worktree", "add", "--detach", path, "HEAD"], { cwd: repoCwd }); + } catch (err) { + throw new WorktreeError( + `Failed to create sub-agent worktree at "${path}": ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + let headAtCreate: string; + try { + const { stdout } = await exec(["rev-parse", "HEAD"], { cwd: path }); + headAtCreate = stdout.trim(); + } catch (err) { + throw new WorktreeError( + `Failed to record HEAD for sub-agent worktree at "${path}": ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + const stashBaseline = await stashList(repoCwd, exec); + return { path, stashBaseline, headAtCreate }; +} + +export type WorktreeCleanupResult = + | { status: "removed"; path: string } + | { status: "preserved"; path: string; notice: string }; + +export type CleanupSubAgentWorktreeOpts = { + // From createSubAgentWorktree.stashBaseline. `null` means unknown → preserve. + stashBaseline?: readonly string[] | null; + // From createSubAgentWorktree.headAtCreate. When set, HEAD advance preserves. + headAtCreate?: string; +}; + +// Removes the worktree if it has no uncommitted changes, no new commits on +// detached HEAD, and no new stash entries; otherwise leaves it in place (the +// sub-agent's work is not ours to discard) and returns a notice the caller +// should surface to the operator. +// `git status` never reports a `git stash` the sub-agent ran mid-task — the +// stash itself survives in the repo's shared refs/stash either way, but +// without this check it goes silently orphaned with no indication of which +// worktree it came from. `stashBaseline` (from createSubAgentWorktree) is +// diffed against the current stash list so only entries created since this +// worktree was checked out are attributed to it. A null baseline (lookup +// failed at create) or a failed stash lookup at cleanup always preserves. +export async function cleanupSubAgentWorktree( + repoCwd: string, + path: string, + opts: CleanupSubAgentWorktreeOpts = {}, + exec: WorktreeExec = defaultExec, +): Promise { + const stashBaseline = opts.stashBaseline === undefined ? [] : opts.stashBaseline; + const headAtCreate = opts.headAtCreate; + + let dirty: boolean; + try { + // --ignored counts gitignored-but-present files (e.g. dist/, logs) as + // content worth preserving — a worktree holding only ignored output is + // not "clean" just because git status ignores it by default. + const { stdout } = await exec(["status", "--porcelain", "--ignored"], { cwd: path }); + dirty = stdout.trim().length > 0; + } catch { + // Cannot inspect the worktree's status — preserve it rather than risk + // discarding work we could not verify was safe to remove. + dirty = true; + } + if (dirty) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} has uncommitted changes and was left in place.`, + }; + } + if (headAtCreate !== undefined) { + try { + const { stdout } = await exec(["rev-parse", "HEAD"], { cwd: path }); + if (stdout.trim() !== headAtCreate) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} was left in place: HEAD advanced from ${headAtCreate.slice(0, 12)} (commits would otherwise be reflog-only after removal).`, + }; + } + } catch { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} was left in place: could not verify HEAD had not advanced.`, + }; + } + } + if (stashBaseline === null) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} was left in place: stash baseline could not be recorded at create time.`, + }; + } + const currentStashes = await stashList(repoCwd, exec); + if (currentStashes === null) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} was left in place: could not inspect the stash list to confirm no new entries.`, + }; + } + const baselineSet = new Set(stashBaseline); + const newStashes = currentStashes.filter((entry) => !baselineSet.has(entry)); + if (newStashes.length > 0) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} was left in place: it created ${ + newStashes.length === 1 ? "a stash entry" : `${newStashes.length} stash entries` + } that would otherwise go unrecovered (${newStashes.join("; ")}).`, + }; + } + try { + await exec(["worktree", "remove", path], { cwd: repoCwd }); + } catch (err) { + return { + status: "preserved", + path, + notice: `Sub-agent worktree at ${path} could not be removed automatically: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + return { status: "removed", path }; +} diff --git a/src/tui/command-display.test.ts b/src/tui/command-display.test.ts index e8599feb7..350442a91 100644 --- a/src/tui/command-display.test.ts +++ b/src/tui/command-display.test.ts @@ -265,3 +265,33 @@ test("collapseSegmentPayloads never collapses an interpreter without any code fl const segment = "bash script.sh"; expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); }); + +test("collapseSegmentPayloads never collapses bun -e code", () => { + const segment = 'bun -e "console.log(1)\nconsole.log(2)"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); + +test("collapseSegmentPayloads never collapses bunx running a package", () => { + const segment = 'bunx cowsay "line one\nline two"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); + +test("collapseSegmentPayloads never collapses deno eval code", () => { + const segment = 'deno eval "console.log(1)\nconsole.log(2)"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); + +test("collapseSegmentPayloads never collapses busybox sh -c", () => { + const segment = 'busybox sh -c "line one\nline two"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); + +test("collapseSegmentPayloads never collapses ash -c", () => { + const segment = 'ash -c "line one\nline two"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); + +test("collapseSegmentPayloads never collapses osascript", () => { + const segment = 'osascript -e "display dialog \\"hi\\"\nbeep"'; + expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] }); +}); diff --git a/src/tui/command-display.ts b/src/tui/command-display.ts index 4b4f95f28..b4450d2ab 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -284,12 +284,18 @@ const CODE_CONSUMING_COMMANDS = new Set([ "sh", "zsh", "dash", + "ash", + "busybox", "python", "python3", "node", + "bun", + "bunx", + "deno", "ruby", "perl", "php", + "osascript", ]); // Command-position words only: the program name and its flags, never text diff --git a/src/tui/components/operator-modal.tsx b/src/tui/components/operator-modal.tsx index 67000e839..c134a89df 100644 --- a/src/tui/components/operator-modal.tsx +++ b/src/tui/components/operator-modal.tsx @@ -44,7 +44,6 @@ function renderMarkdownLines(lines: readonly StyledSegment[][]): ReactNode { ); } - // Two-column layout when all options are short enough to fit side by side. // Each column gets half the inner width minus a small gap for the number prefix. function renderOptionsGrid(options: string[], selected: number, innerWidth: number): ReactNode {