From c66f63d58e1f54b598db216885b5e826bb3f5f81 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 31 Jul 2026 21:38:47 -0700 Subject: [PATCH 1/6] Let sub-agent dispatch run in an isolated git worktree Sub-agents share the dispatcher's cwd today, so parallel workers collide on the same working tree and the approval-prompt attribution has nothing distinct to show. task() can now opt into worktree isolation: each spawn gets a fresh worktree branched from the dispatcher's HEAD, and its tools resolve against that path instead of the parent cwd, so approval prompts (which already read the sub-agent identity's cwd) surface the worktree path automatically. Worktree setup fails closed when the dispatcher cwd is not a git repository or worktree creation fails. On completion the worktree is removed if unchanged; if the sub-agent left uncommitted changes, it is preserved and a notice is appended to the returned report. --- src/agent/tools.ts | 6 + src/subagent/index.ts | 9 ++ src/subagent/run.ts | 1 + src/subagent/task-tool-worktree.test.ts | 172 ++++++++++++++++++++++++ src/subagent/task-tool.ts | 60 +++++++-- src/subagent/types.ts | 3 + src/subagent/worktree.test.ts | 105 +++++++++++++++ src/subagent/worktree.ts | 91 +++++++++++++ 8 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 src/subagent/task-tool-worktree.test.ts create mode 100644 src/subagent/worktree.test.ts create mode 100644 src/subagent/worktree.ts 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 { ...(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..d99e8ae72 --- /dev/null +++ b/src/subagent/task-tool-worktree.test.ts @@ -0,0 +1,172 @@ +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!); + }); +}); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 4bc6d1abd..d3ba63e0f 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, @@ -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,42 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { }); } + let worktreeCwd: string | undefined; + if (deps.useWorktree === true) { + const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId()); + try { + const worktree = await createSubAgentWorktree(deps.cwd, worktreePath); + worktreeCwd = worktree.path; + } 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); + 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 } : {}), @@ -529,15 +572,16 @@ 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}`, +const reported = appendSubAgentParentHints(result, hintOptions); + 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) { @@ -553,7 +597,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 +611,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..7751f6a3b --- /dev/null +++ b/src/subagent/worktree.test.ts @@ -0,0 +1,105 @@ +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); + const key = args[0]!; + const response = responses[key]; + 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": { stdout: "/repo\n" }, + worktree: { stdout: "" }, + }); + const result = await createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result.path).toBe("/repo/.worktrees/abc"); + expect(calls).toEqual([ + ["rev-parse", "--show-toplevel"], + ["worktree", "add", "--detach", "/repo/.worktrees/abc", "HEAD"], + ]); + }); + + test("fails closed when repoCwd is not a git repository", async () => { + const { exec } = recordingExec({ + "rev-parse": { 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": { 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", async () => { + const { exec, calls } = recordingExec({ + status: { stdout: "" }, + worktree: { stdout: "" }, + }); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); + expect(calls).toEqual([ + ["status", "--porcelain"], + ["worktree", "remove", "/repo/.worktrees/abc"], + ]); + }); + + 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", 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", exec); + expect(result.status).toBe("preserved"); + }); + + test("preserves the worktree when removal fails", async () => { + const { exec } = recordingExec({ + status: { stdout: "" }, + worktree: { error: new Error("worktree is locked") }, + }); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + expect(result.status).toBe("preserved"); + if (result.status === "preserved") { + expect(result.notice).toContain("could not be removed automatically"); + } + }); +}); diff --git a/src/subagent/worktree.ts b/src/subagent/worktree.ts new file mode 100644 index 000000000..55cd0abb7 --- /dev/null +++ b/src/subagent/worktree.ts @@ -0,0 +1,91 @@ +/** + * 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 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; +}; + +// 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 { + throw new WorktreeError( + `Cannot create an isolated sub-agent worktree: "${repoCwd}" is not inside a git repository.`, + ); + } + 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)}`, + ); + } + return { path }; +} + +export type WorktreeCleanupResult = + | { status: "removed"; path: string } + | { status: "preserved"; path: string; notice: string }; + +// Removes the worktree if it has no uncommitted changes; 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. +export async function cleanupSubAgentWorktree( + repoCwd: string, + path: string, + exec: WorktreeExec = defaultExec, +): Promise { + let dirty: boolean; + try { + const { stdout } = await exec(["status", "--porcelain"], { 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.`, + }; + } + 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 }; +} From 89d422440818e80c9a32a978e62ca043ce72a11f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 1 Aug 2026 10:50:39 -0700 Subject: [PATCH 2/6] Count gitignored-but-present files as content worth preserving Cleanup checked git status --porcelain, which omits gitignored files, so a sub-agent worktree holding only ignored output (dist/, logs) looked clean and was deleted along with that output. Adding --ignored makes dirty-detection catch it too. --- src/subagent/worktree.test.ts | 14 +++++++++++++- src/subagent/worktree.ts | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/subagent/worktree.test.ts b/src/subagent/worktree.test.ts index 7751f6a3b..5d1ec33b9 100644 --- a/src/subagent/worktree.test.ts +++ b/src/subagent/worktree.test.ts @@ -64,11 +64,23 @@ describe("cleanupSubAgentWorktree", () => { const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); expect(calls).toEqual([ - ["status", "--porcelain"], + ["status", "--porcelain", "--ignored"], ["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", 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" }, diff --git a/src/subagent/worktree.ts b/src/subagent/worktree.ts index 55cd0abb7..9bb68a0da 100644 --- a/src/subagent/worktree.ts +++ b/src/subagent/worktree.ts @@ -62,7 +62,10 @@ export async function cleanupSubAgentWorktree( ): Promise { let dirty: boolean; try { - const { stdout } = await exec(["status", "--porcelain"], { cwd: path }); + // --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 From f19db9f20f416099ae3b1bdca56eecef62b3709b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 1 Aug 2026 10:50:45 -0700 Subject: [PATCH 3/6] Correct the sub-agent tool description's working-tree claim The task tool description and the isolated-run comment both said the sub-agent unconditionally shares your working tree, which is only true in the default dispatch mode. Worktree isolation snapshots the dispatcher's last commit, so uncommitted and untracked changes are never visible to a sub-agent running in that mode. --- src/subagent/run.ts | 11 ++++++----- src/subagent/task-tool.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5d42591e7..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, diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index d3ba63e0f..3e9f751e1 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -64,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: { From c4dde093a17498b30f107b024f0ca60ec0b3479f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 1 Aug 2026 21:36:52 -0700 Subject: [PATCH 4/6] Preserve a worktree instead of silently dropping its stash entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git status never reports a git stash the sub-agent ran mid-task, so a worktree holding one looked clean and got removed automatically — the stash entry survives in the repo's shared refs/stash either way, but went silently orphaned with no indication of which worktree it came from. createSubAgentWorktree now captures the stash list as a baseline; cleanup diffs the current list against it and preserves the worktree with a notice naming the new stash entries instead of removing it. Also preserves the caught error as "cause" on both WorktreeError throw sites in this file, and collapses two stray double blank lines. --- src/subagent/task-tool-worktree.test.ts | 41 ++++++++++++++++++ src/subagent/task-tool.ts | 5 ++- src/subagent/worktree.test.ts | 55 +++++++++++++++++++++---- src/subagent/worktree.ts | 50 +++++++++++++++++++--- src/tui/components/operator-modal.tsx | 1 - 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/src/subagent/task-tool-worktree.test.ts b/src/subagent/task-tool-worktree.test.ts index d99e8ae72..b76dd1e26 100644 --- a/src/subagent/task-tool-worktree.test.ts +++ b/src/subagent/task-tool-worktree.test.ts @@ -169,4 +169,45 @@ describe("createTaskTool worktree isolation", () => { 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 3e9f751e1..c3d7c4de0 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -490,11 +490,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } let worktreeCwd: string | undefined; + let worktreeStashBaseline: readonly string[] = []; 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; } catch (err) { // Admit already happened and the strip session may be "running" — // release the ledger slot and fail the session so a worktree setup @@ -514,7 +516,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { // (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); + const cleanup = await cleanupSubAgentWorktree(deps.cwd, worktreeCwd, worktreeStashBaseline); if (cleanup.status === "preserved") { return { ...result, content: `${result.content}\n\n${cleanup.notice}` }; } @@ -583,7 +585,6 @@ const reported = appendSubAgentParentHints(result, hintOptions); taskToolResult(call.id, `Sub-agent "${description}" reported:\n\n${reported}`), ); - } catch (err) { if ( isSubAgentCancelError(err, childCtl.signal) || diff --git a/src/subagent/worktree.test.ts b/src/subagent/worktree.test.ts index 5d1ec33b9..055935171 100644 --- a/src/subagent/worktree.test.ts +++ b/src/subagent/worktree.test.ts @@ -26,15 +26,28 @@ describe("createSubAgentWorktree", () => { const { exec, calls } = recordingExec({ "rev-parse": { stdout: "/repo\n" }, worktree: { stdout: "" }, + stash: { stdout: "" }, }); const result = await createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); expect(result.path).toBe("/repo/.worktrees/abc"); + expect(result.stashBaseline).toEqual([]); expect(calls).toEqual([ ["rev-parse", "--show-toplevel"], ["worktree", "add", "--detach", "/repo/.worktrees/abc", "HEAD"], + ["stash", "list"], ]); }); + test("captures the current stash list as a baseline", async () => { + const { exec } = recordingExec({ + "rev-parse": { stdout: "/repo\n" }, + worktree: { stdout: "" }, + 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("fails closed when repoCwd is not a git repository", async () => { const { exec } = recordingExec({ "rev-parse": { error: new Error("not a git repository") }, @@ -56,15 +69,17 @@ describe("createSubAgentWorktree", () => { }); describe("cleanupSubAgentWorktree", () => { - test("removes a clean worktree", async () => { + 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", exec); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); expect(calls).toEqual([ ["status", "--porcelain", "--ignored"], + ["stash", "list"], ["worktree", "remove", "/repo/.worktrees/abc"], ]); }); @@ -73,7 +88,7 @@ describe("cleanupSubAgentWorktree", () => { const { exec, calls } = recordingExec({ status: { stdout: "!! dist/output.txt\n" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); expect(result.status).toBe("preserved"); if (result.status === "preserved") { expect(result.notice).toContain("uncommitted changes"); @@ -85,7 +100,7 @@ describe("cleanupSubAgentWorktree", () => { const { exec, calls } = recordingExec({ status: { stdout: " M src/index.ts\n" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); expect(result.status).toBe("preserved"); expect(result).toMatchObject({ path: "/repo/.worktrees/abc" }); if (result.status === "preserved") { @@ -99,19 +114,45 @@ describe("cleanupSubAgentWorktree", () => { const { exec } = recordingExec({ status: { error: new Error("no such directory") }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); expect(result.status).toBe("preserved"); }); test("preserves the worktree when removal fails", async () => { - const { exec } = recordingExec({ + const { exec, calls } = recordingExec({ status: { stdout: "" }, + stash: { stdout: "" }, worktree: { error: new Error("worktree is locked") }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec); + const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], 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", [], 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", [preexisting], exec); + expect(result).toEqual({ status: "removed", path: "/repo/.worktrees/abc" }); + }); }); diff --git a/src/subagent/worktree.ts b/src/subagent/worktree.ts index 9bb68a0da..cc7f2585d 100644 --- a/src/subagent/worktree.ts +++ b/src/subagent/worktree.ts @@ -20,8 +20,26 @@ 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. + stashBaseline: string[]; }; +// The repo's stash list as an array of "stash@{N}: " lines (empty +// when there are none, or when the lookup itself fails — a failed lookup +// must never make cleanup MORE willing to remove a worktree, so it degrades +// to "no visible change" rather than to "worktree is clean"). +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 []; + } +} + // 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 @@ -33,9 +51,10 @@ export async function createSubAgentWorktree( ): Promise { try { await exec(["rev-parse", "--show-toplevel"], { cwd: repoCwd }); - } catch { + } catch (err) { throw new WorktreeError( `Cannot create an isolated sub-agent worktree: "${repoCwd}" is not inside a git repository.`, + { cause: err }, ); } try { @@ -43,21 +62,30 @@ export async function createSubAgentWorktree( } catch (err) { throw new WorktreeError( `Failed to create sub-agent worktree at "${path}": ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, ); } - return { path }; + const stashBaseline = await stashList(repoCwd, exec); + return { path, stashBaseline }; } export type WorktreeCleanupResult = | { status: "removed"; path: string } | { status: "preserved"; path: string; notice: string }; -// Removes the worktree if it has no uncommitted changes; 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. +// Removes the worktree if it has no uncommitted changes 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. export async function cleanupSubAgentWorktree( repoCwd: string, path: string, + stashBaseline: readonly string[] = [], exec: WorktreeExec = defaultExec, ): Promise { let dirty: boolean; @@ -79,6 +107,18 @@ export async function cleanupSubAgentWorktree( notice: `Sub-agent worktree at ${path} has uncommitted changes and was left in place.`, }; } + const currentStashes = await stashList(repoCwd, exec); + 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) { 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 { From 967c4a55755e4326ee525ab0b177c54691827c6b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 3 Aug 2026 17:27:31 -0700 Subject: [PATCH 5/6] Preserve worktrees when stash is unknown or detached HEAD advanced Record HEAD at create, treat a null stash baseline as unknown (preserve), preserve when stash list fails at cleanup, and pass both through finishWithWorktree so commits on detached HEAD are not left reflog-only. --- src/subagent/task-tool.ts | 11 ++- src/subagent/worktree.test.ts | 150 ++++++++++++++++++++++++++++++---- src/subagent/worktree.ts | 88 ++++++++++++++++---- 3 files changed, 218 insertions(+), 31 deletions(-) diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index c3d7c4de0..5c8ad50bf 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -490,13 +490,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } let worktreeCwd: string | undefined; - let worktreeStashBaseline: readonly string[] = []; + 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 @@ -516,7 +518,10 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { // (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, worktreeStashBaseline); + 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}` }; } @@ -574,7 +579,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ) { deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); } -const reported = appendSubAgentParentHints(result, hintOptions); + const reported = appendSubAgentParentHints(result, hintOptions); return finishWithWorktree( taskToolResult(call.id, `Sub-agent "${description}" reported:\n\n${reported}`), ); diff --git a/src/subagent/worktree.test.ts b/src/subagent/worktree.test.ts index 055935171..1e03b6e31 100644 --- a/src/subagent/worktree.test.ts +++ b/src/subagent/worktree.test.ts @@ -13,8 +13,11 @@ function recordingExec( const calls: string[][] = []; const exec: WorktreeExec = async (args) => { calls.push(args); - const key = args[0]!; - const response = responses[key]; + // 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: "" }; }; @@ -24,33 +27,48 @@ function recordingExec( describe("createSubAgentWorktree", () => { test("creates a detached worktree at HEAD when repoCwd is a git repo", async () => { const { exec, calls } = recordingExec({ - "rev-parse": { stdout: "/repo\n" }, + "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": { stdout: "/repo\n" }, + "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": { error: new Error("not a git repository") }, + "rev-parse --show-toplevel": { error: new Error("not a git repository") }, }); await expect(createSubAgentWorktree("/not-a-repo", "/tmp/wt", exec)).rejects.toThrow( WorktreeError, @@ -59,7 +77,7 @@ describe("createSubAgentWorktree", () => { test("fails closed when worktree add fails", async () => { const { exec } = recordingExec({ - "rev-parse": { stdout: "/repo\n" }, + "rev-parse --show-toplevel": { stdout: "/repo\n" }, worktree: { error: new Error("worktree already exists") }, }); await expect(createSubAgentWorktree("/repo", "/repo/.worktrees/abc", exec)).rejects.toThrow( @@ -75,7 +93,12 @@ describe("cleanupSubAgentWorktree", () => { stash: { stdout: "" }, worktree: { stdout: "" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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"], @@ -88,7 +111,12 @@ describe("cleanupSubAgentWorktree", () => { const { exec, calls } = recordingExec({ status: { stdout: "!! dist/output.txt\n" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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"); @@ -100,7 +128,12 @@ describe("cleanupSubAgentWorktree", () => { const { exec, calls } = recordingExec({ status: { stdout: " M src/index.ts\n" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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") { @@ -114,17 +147,27 @@ describe("cleanupSubAgentWorktree", () => { const { exec } = recordingExec({ status: { error: new Error("no such directory") }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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, calls } = recordingExec({ + const { exec } = recordingExec({ status: { stdout: "" }, stash: { stdout: "" }, worktree: { error: new Error("worktree is locked") }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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"); @@ -136,7 +179,12 @@ describe("cleanupSubAgentWorktree", () => { status: { stdout: "" }, stash: { stdout: "stash@{0}: WIP on (no branch): abc1234 sub-agent work\n" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [], exec); + 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"); @@ -152,7 +200,81 @@ describe("cleanupSubAgentWorktree", () => { stash: { stdout: `${preexisting}\n` }, worktree: { stdout: "" }, }); - const result = await cleanupSubAgentWorktree("/repo", "/repo/.worktrees/abc", [preexisting], exec); + 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 index cc7f2585d..2fe68a2ab 100644 --- a/src/subagent/worktree.ts +++ b/src/subagent/worktree.ts @@ -1,7 +1,8 @@ /** * 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 behind. + * sub-agent finishes, unless it left uncommitted changes, new commits, or + * stash entries behind. */ import { execFile } from "node:child_process"; @@ -24,19 +25,24 @@ export type SubAgentWorktree = { // 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. - stashBaseline: string[]; + // `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 (empty -// when there are none, or when the lookup itself fails — a failed lookup -// must never make cleanup MORE willing to remove a worktree, so it degrades -// to "no visible change" rather than to "worktree is clean"). -async function stashList(repoCwd: string, exec: WorktreeExec): Promise { +// 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 []; + return null; } } @@ -65,29 +71,51 @@ export async function createSubAgentWorktree( { 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 }; + return { path, stashBaseline, headAtCreate }; } export type WorktreeCleanupResult = | { status: "removed"; path: string } | { status: "preserved"; path: string; notice: string }; -// Removes the worktree if it has no uncommitted changes 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. +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. +// 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, - stashBaseline: readonly 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 @@ -107,7 +135,39 @@ export async function cleanupSubAgentWorktree( 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) { From 184f2dac72f1d7cf1c92500b0ab8d860050c99ae Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 3 Aug 2026 19:40:54 -0700 Subject: [PATCH 6/6] Judge shell auto-allow and restriction against the process cwd Sub-agent worktrees run with a different process cwd than the session that built the gate. Relative path checks for auto-allow and restriction must resolve against that process cwd so a cat of a worktree-local path is not judged as if it opened under the session. Also refuse to collapse payloads for bun, deno, busybox, ash, and osascript in the approval dialog. --- src/permission/gate.test.ts | 32 ++++++++++----- src/permission/gate.ts | 51 ++++++++++++++++++------ src/permission/permission.test.ts | 66 +++++++++++++++++++++++++++++++ src/tui/command-display.test.ts | 30 ++++++++++++++ src/tui/command-display.ts | 6 +++ 5 files changed, 163 insertions(+), 22 deletions(-) diff --git a/src/permission/gate.test.ts b/src/permission/gate.test.ts index 8870581e8..fefed1d5d 100644 --- a/src/permission/gate.test.ts +++ b/src/permission/gate.test.ts @@ -89,13 +89,11 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => { }); }); -// 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/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