diff --git a/README.md b/README.md index 17a75bc..5500be0 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ pi install git:github.com/IzumiSy/pi-packages | [`tiggy`](./extensions/tiggy/README.md) | Browse git history and working tree diffs in a TUI, with previews and in-preview search. | | [`git-status-widget`](./extensions/git-status-widget/README.md) | Shows current git status entries for the working tree, including staged, unstaged, and untracked files. | | [`command-gate`](./extensions/command-gate/README.md) | Blocks or asks for confirmation on matching bash commands, and provides tools to inspect and manage rules. | +| [`command-handoff`](./extensions/command-handoff/README.md) | Provides `request_command_handoff` to stage user-run shell commands and resume after their results. | | [`project-notes`](./extensions/project-notes/README.md) | Keeps personal Markdown notes outside the repository, shared across clones with the same origin. | ## Skills - [`cymbal`](./skills/cymbal/SKILL.md) — Prefer [Cymbal](https://cymbal.sh/) for code search and navigation over grep, ripgrep, and find. Requires the `cymbal` CLI. +- [`permission-handoff`](./skills/permission-handoff/SKILL.md) — Guides permission-error recovery through `request_command_handoff`, without automatically retrying or escalating privileges. - [`pr-review-plan`](./skills/pr-review-plan/SKILL.md) — Collect unresolved GitHub PR review threads and propose a concrete plan for each. Invoke with `/skill:pr-review-plan`; requires Node.js, `git`, and authenticated `gh`. For development notes, see [DEVELOPMENT.md](./DEVELOPMENT.md). diff --git a/extensions/command-handoff/README.md b/extensions/command-handoff/README.md new file mode 100644 index 0000000..cc1a17a --- /dev/null +++ b/extensions/command-handoff/README.md @@ -0,0 +1,44 @@ +# command-handoff + +Provide `request_command_handoff` to ask the user to run a shell command, then resume the task after its result is recorded. The tool does not execute commands or interpret errors. + +The companion [permission-handoff skill](../../skills/permission-handoff/SKILL.md) guides the model to use it for permission errors. There is no automatic permission-error hook; skill selection and the decision to call the tool are model-driven. + +## Entry point + +- `./main.ts` + +## Tool + +`request_command_handoff` takes one required argument: + +```json +{ + "command": "ps aux | grep '[v]ite'" +} +``` + +Pass the command without a leading `!`. Call the tool alone, not alongside other tools. It is model-only on Pi versions supporting tool exposure and requests sequential execution. It never grants permissions or overrides a user refusal or security policy. + +## Setup + +Commands use Pi's normal `!` execution path. This extension does not change user-shell settings or bypass other extensions' `user_bash` handlers. + +## Behavior + +Calling `request_command_handoff` in the interactive TUI opens a Yes/No confirmation for the supplied command, regardless of why the model requested handoff. + +- **Yes:** stop the agent and put the original command, unchanged except for a leading `!`, into the message input. Review/edit it, then press Enter to execute it yourself. Nothing is executed automatically. +- After you execute the handed-off command unchanged, the agent automatically continues once its result is recorded, whether the command succeeds or fails. No extra "continue" message is needed. Ordinary `!` commands are unaffected. +- **No / Esc:** leave the input and agent operation unchanged. +- The confirmation shows a muted heading and the command in bold accent text, without explanatory or warning messages. An existing input draft is replaced only after confirmation. +- Concurrent requests are offered one at a time. Accepting one stops further handoffs for that operation. +- If messages are queued, handoff is skipped with an explanation in the tool result to avoid losing them when Pi restores them into the editor on abort. If the input or queue changes during confirmation, handoff is also skipped rather than overwriting the new content. +- Auto-resume is cancelled if the command is edited, the execution is cancelled, `!!` is used, another command is submitted, a different shell result is recorded, a new draft or queued message appears, or you submit another prompt or switch sessions/branches. In those cases, continue manually if needed. Outer command whitespace is ignored when matching, as in Pi. +- RPC, JSON, and print modes reject tool calls with an interactive-TUI-required error. + +## Limits + +Pi has no public user-shell completion event, so this extension checks in-memory history every 250 ms only while a handoff is pending. It matches the next shell result by command, session, and branch. While waiting, it also observes the configured submit key to cancel on an edited or second submission without consuming that key. It does not intercept execution or change other extensions' shell handlers. Because results have no execution correlation ID, clearing the staged command and later running the exact same command without another detected action can still resume the agent. Edits made and reverted between checks cannot be detected. Pending handoffs are not restored after reload. + +A failed command may already have completed earlier steps. Executing the whole command again can repeat their side effects; review it before submitting. Stopping the agent cannot undo work already performed by other tools running in parallel. diff --git a/extensions/command-handoff/main.ts b/extensions/command-handoff/main.ts new file mode 100644 index 0000000..b3966f7 --- /dev/null +++ b/extensions/command-handoff/main.ts @@ -0,0 +1,175 @@ +import { + defineTool, + type ExtensionAPI, + type ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { getKeybindings, isKeyRelease } from "@earendil-works/pi-tui"; + +function textResult(text: string) { + return { content: [{ type: "text" as const, text }], details: undefined }; +} + +export default function commandHandoff(pi: ExtensionAPI) { + let pending = Promise.resolve(); + let pollTimer: ReturnType | undefined; + let removeInputListener: (() => void) | undefined; + + function stopWatching() { + clearInterval(pollTimer); + pollTimer = undefined; + removeInputListener?.(); + removeInputListener = undefined; + } + + function watchCommandResult(command: string, ctx: ExtensionContext) { + stopWatching(); + const sessionId = ctx.sessionManager.getSessionId(); + let cursor = ctx.sessionManager.getLeafId(); + let completed = false; + let submitted = false; + + // Shell submissions bypass input hooks, and earlier user_bash handlers can hide them. + removeInputListener = ctx.ui.onTerminalInput((data) => { + if (isKeyRelease(data) || !getKeybindings().matches(data, "tui.input.submit")) return; + const text = ctx.ui.getEditorText().trim(); + if ( + submitted || + !text.startsWith("!") || + text.startsWith("!!") || + text.slice(1).trim() !== command.trim() + ) { + stopWatching(); + return; + } + submitted = true; + }); + + // shortcut: Pi has no user-shell completion event; poll new history entries until it does. + pollTimer = setInterval(() => { + const draft = ctx.ui.getEditorText(); + if ( + ctx.sessionManager.getSessionId() !== sessionId || + ctx.hasPendingMessages() || + (draft !== "" && draft !== `!${command}`) + ) { + stopWatching(); + return; + } + + const leaf = ctx.sessionManager.getLeafId(); + if (leaf !== cursor) { + const branch = ctx.sessionManager.getBranch(); + const index = cursor === null ? -1 : branch.findIndex((entry) => entry.id === cursor); + if (cursor !== null && index === -1) { + stopWatching(); + return; + } + for (const entry of branch.slice(index + 1)) { + if (entry.type !== "message" || entry.message.role !== "bashExecution") continue; + const result = entry.message; + if ( + completed || + result.command !== command.trim() || + result.cancelled || + result.excludeFromContext + ) { + stopWatching(); + return; + } + completed = true; + } + cursor = leaf; + } + if (!completed || !ctx.isIdle()) return; + + stopWatching(); + pi.sendMessage( + { + customType: "command-handoff-resume", + content: + "The handed-off shell command has completed. Continue the original task using its recorded result.", + display: false, + }, + { triggerTurn: true }, + ); + }, 250); + pollTimer.unref?.(); + } + + pi.on("input", stopWatching); + pi.on("before_agent_start", stopWatching); + pi.on("session_before_switch", stopWatching); + pi.on("session_before_fork", stopWatching); + pi.on("session_before_tree", stopWatching); + pi.on("session_start", stopWatching); + pi.on("session_shutdown", stopWatching); + + const tool = { + exposure: "model-only" as const, + ...defineTool({ + name: "request_command_handoff", + label: "Command handoff", + description: + "Ask the user to run a shell command. Confirms and stages editable ! input in the interactive TUI, then pauses the agent; does not execute the command. Resumes once after the unchanged command's result is recorded. Call this tool alone, not alongside other tools. Do not use it to bypass a user's refusal or security policy.", + executionMode: "sequential", + parameters: { + type: "object", + properties: { + command: { + type: "string", + minLength: 1, + description: "Exact shell command to hand to the user, without a leading ! prefix", + }, + }, + required: ["command"], + additionalProperties: false, + } as any, + async execute(_toolCallId, { command }: { command: string }, signal, _onUpdate, ctx) { + if (ctx.mode !== "tui") throw new Error("Command handoff requires the interactive TUI."); + if (typeof command !== "string" || !command.trim()) { + throw new Error("command must be a non-empty string."); + } + const operationSignal = signal ?? ctx.signal; + const handoff = pending.then(async () => { + if (operationSignal?.aborted) return textResult("Command handoff cancelled."); + // Pi restores queued messages into the editor on abort; do not overwrite them. + if (ctx.hasPendingMessages()) { + return textResult("Command handoff skipped: queued messages would be overwritten."); + } + const draft = ctx.ui.getEditorText(); + const confirmed = await ctx.ui.confirm( + ctx.ui.theme.fg("muted", "Hand off to your shell?"), + `\n${ctx.ui.theme.fg("accent", ctx.ui.theme.bold(command))}\n`, + operationSignal ? { signal: operationSignal } : undefined, + ); + if (operationSignal?.aborted) return textResult("Command handoff cancelled."); + if (!confirmed) { + return textResult( + "Command handoff declined. Do not retry or bypass the user's decision.", + ); + } + if (ctx.hasPendingMessages() || ctx.ui.getEditorText() !== draft) { + return textResult( + "Command handoff skipped: input or queued messages changed during confirmation.", + ); + } + ctx.abort(); + ctx.ui.setEditorText(`!${command}`); + watchCommandResult(command, ctx); + return { + ...textResult( + "Command handed off to the user. Nothing has been executed; wait for the recorded shell result.", + ), + terminate: true, + }; + }); + pending = handoff.then( + () => {}, + () => {}, + ); + return handoff; + }, + }), + }; + pi.registerTool(tool); +} diff --git a/package.json b/package.json index 666ad9f..8748ac0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "./extensions/tiggy/main.ts", "./extensions/git-status-widget/main.ts", "./extensions/command-gate/main.ts", + "./extensions/command-handoff/main.ts", "./extensions/project-notes/main.ts" ], "skills": [ diff --git a/skills/permission-handoff/SKILL.md b/skills/permission-handoff/SKILL.md new file mode 100644 index 0000000..8ea8f6d --- /dev/null +++ b/skills/permission-handoff/SKILL.md @@ -0,0 +1,19 @@ +--- +name: permission-handoff +description: Handle shell commands blocked by Permission denied, Operation not permitted, or an OS-level sandbox restriction. Use when a necessary command cannot run with the agent's permissions and should be handed to the user through request_command_handoff instead of automatically retried. +compatibility: Requires the request_command_handoff tool in Pi's interactive TUI. +--- + +# Permission Handoff + +1. Check that the failure is a permission restriction and that the command is still needed for the user's task. Do not treat an ordinary command failure as a permission error. +2. Respect explicit user refusals, command-gate denials, and security policies. Handoff is not a way to override them or obtain permission that the user has withheld. +3. Do not automatically retry through `sudo`, permission changes, another interpreter, another tool, or an alternate execution path to evade the restriction. +4. If execution in the user's own shell is appropriate, call `request_command_handoff` with the original command as `command`, without adding a `!` prefix. Call it alone, not in parallel with other tools. The tool only asks for confirmation and stages editable input; the user decides whether to execute it. +5. A failed command may have completed earlier steps. If repeating it could cause data loss or duplicate irreversible side effects, clarify with the user before requesting handoff. +6. On acceptance, let the tool pause the agent and resume automatically after the unchanged command's result is recorded. Use that result to continue the original task; do not rerun the command just to obtain the same output or send an extra continuation message. +7. If handoff is declined, cancelled, or skipped, do not repeatedly request it or work around the decision. If the tool is unavailable, explain the restriction and ask the user how to proceed. + +## Environment note + +Handoff uses Pi's normal `!` execution path; it does not grant permissions or disable sandboxing. If pi-sandbox is installed, user-shell sandboxing depends on its `sandboxUserShell` setting. If the user-run command is also blocked, explain the restriction and ask the user how to proceed. Do not change sandbox settings automatically. diff --git a/tests/command-handoff.test.ts b/tests/command-handoff.test.ts new file mode 100644 index 0000000..69eeb36 --- /dev/null +++ b/tests/command-handoff.test.ts @@ -0,0 +1,578 @@ +import { resolve } from "node:path"; +import { + loadSkillsFromDir, + SessionManager, + type ExtensionAPI, + type ExtensionContext, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import commandHandoff from "../extensions/command-handoff/main.ts"; + +type BashMessage = Extract< + Parameters[0], + { role: "bashExecution" } +>; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +function mount() { + const handlers = new Map unknown>(); + let tool!: ToolDefinition & { exposure?: string }; + const api = { + on: vi.fn((name: string, callback: (event: never, ctx: ExtensionContext) => unknown) => { + handlers.set(name, callback); + }), + registerTool: vi.fn((definition: typeof tool) => { + tool = definition; + }), + sendMessage: vi.fn(), + }; + const controller = new AbortController(); + let editor = ""; + let terminalInput: Parameters[0] | undefined; + const unsubscribe = vi.fn(() => { + terminalInput = undefined; + }); + const ui = { + theme: { + fg: vi.fn((color: string, text: string) => `<${color}>${text}`), + bold: vi.fn((text: string) => `${text}`), + }, + onTerminalInput: vi.fn((handler: NonNullable) => { + terminalInput = handler; + return unsubscribe; + }), + confirm: vi.fn().mockResolvedValue(true), + getEditorText: vi.fn(() => editor), + setEditorText: vi.fn((text: string) => { + editor = text; + }), + notify: vi.fn(), + }; + const ctx = { + mode: "tui", + signal: controller.signal, + sessionManager: SessionManager.inMemory(), + isIdle: vi.fn(() => true), + hasPendingMessages: vi.fn(() => false), + abort: vi.fn(() => controller.abort()), + ui, + }; + commandHandoff(api as unknown as ExtensionAPI); + const emit = (name: string, event: unknown = {}) => + handlers.get(name)?.(event as never, ctx as unknown as ExtensionContext); + return { + api, + tool, + ctx, + controller, + ui, + emit, + unsubscribe, + terminalInput: (data: string) => terminalInput?.(data), + submitShell: (text = editor) => { + editor = text; + const result = terminalInput?.("\r"); + editor = ""; + return result; + }, + editor: () => editor, + recordShell: (overrides: Partial = {}) => { + editor = ""; + return ctx.sessionManager.appendMessage({ + role: "bashExecution", + command: "touch /restricted/file", + output: "shell output", + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: Date.now(), + ...overrides, + }); + }, + result: (command: unknown = "touch /restricted/file", signal = controller.signal) => + tool.execute( + "call-1", + { command }, + signal, + undefined, + ctx as unknown as Parameters[4], + ), + }; +} + +describe("command-handoff", () => { + it("registers a sequential, model-only tool with only a command argument", () => { + const app = mount(); + expect(app.api.registerTool).toHaveBeenCalledOnce(); + expect(app.tool).toMatchObject({ + name: "request_command_handoff", + exposure: "model-only", + executionMode: "sequential", + parameters: { + type: "object", + properties: { command: { type: "string", minLength: 1 } }, + required: ["command"], + additionalProperties: false, + }, + }); + expect(app.tool.parameters).toHaveProperty("properties", { + command: expect.objectContaining({ type: "string", minLength: 1 }), + }); + expect(app.api.on.mock.calls.map(([name]) => name)).not.toContain("tool_result"); + }); + + it("loads the companion permission-handoff skill for automatic model selection", () => { + const result = loadSkillsFromDir({ + dir: resolve("skills/permission-handoff"), + source: "test", + }); + expect(result.diagnostics).toEqual([]); + expect(result.skills).toEqual([ + expect.objectContaining({ + name: "permission-handoff", + description: expect.stringContaining("request_command_handoff"), + disableModelInvocation: false, + }), + ]); + }); + + it("highlights only the command and stages it unchanged without running it", async () => { + const app = mount(); + const command = " printf '%s\\n' 'hello world' &&\n touch /restricted/file "; + await expect(app.result(command)).resolves.toMatchObject({ + content: [{ type: "text", text: expect.stringContaining("Nothing has been executed") }], + terminate: true, + }); + expect(app.ui.confirm).toHaveBeenCalledWith( + "Hand off to your shell?", + `\n${command}\n`, + { signal: app.controller.signal }, + ); + expect(app.ctx.abort).toHaveBeenCalledOnce(); + expect(app.editor()).toBe(`!${command}`); + }); + + it("does not react to permission errors unless the tool is called", async () => { + const app = mount(); + app.emit("tool_result", { + toolName: "bash", + input: { command: "touch /restricted/file" }, + content: [{ type: "text", text: "Operation not permitted" }], + isError: true, + }); + await vi.advanceTimersByTimeAsync(250); + expect(app.ui.confirm).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([false, undefined])( + "leaves input and agent unchanged on No/cancel (%s)", + async (answer) => { + const app = mount(); + app.ui.setEditorText("my draft"); + app.ui.setEditorText.mockClear(); + app.ui.confirm.mockResolvedValueOnce(answer as boolean); + const result = await app.result(); + expect(result).not.toHaveProperty("terminate"); + expect(result.content).toEqual([{ type: "text", text: expect.stringContaining("declined") }]); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(app.editor()).toBe("my draft"); + }, + ); + + it("replaces an existing draft after confirmation without extra warnings", async () => { + const app = mount(); + app.ui.setEditorText("my draft"); + await app.result(); + expect(app.ui.confirm.mock.calls[0]?.[1]).toBe( + "\ntouch /restricted/file\n", + ); + expect(app.editor()).toBe("!touch /restricted/file"); + }); + + it.each([null, 123, "", " \n"])("rejects an invalid command: %j", async (command) => { + const app = mount(); + await expect(app.result(command)).rejects.toThrow("command must be a non-empty string"); + expect(app.ui.confirm).not.toHaveBeenCalled(); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["rpc", "json", "print"])("rejects handoff in %s mode", async (mode) => { + const app = mount(); + app.ctx.mode = mode; + await expect(app.result()).rejects.toThrow("requires the interactive TUI"); + expect(app.ui.confirm).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + }); + + it("does not discard queued messages when abort would restore them into the editor", async () => { + const app = mount(); + app.ctx.hasPendingMessages.mockReturnValue(true); + const result = await app.result(); + expect(result).not.toHaveProperty("terminate"); + expect(result.content).toEqual([ + { type: "text", text: expect.stringContaining("queued messages") }, + ]); + expect(app.ui.confirm).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + }); + + it.each(["draft", "queue"])("preserves a %s changed during confirmation", async (change) => { + const app = mount(); + app.ui.confirm.mockImplementationOnce(async () => { + if (change === "draft") app.ui.setEditorText("new draft"); + else app.ctx.hasPendingMessages.mockReturnValue(true); + app.ui.setEditorText.mockClear(); + return true; + }); + const result = await app.result(); + expect(result).not.toHaveProperty("terminate"); + expect(result.content).toEqual([ + { type: "text", text: expect.stringContaining("changed during confirmation") }, + ]); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + if (change === "draft") expect(app.editor()).toBe("new draft"); + }); + + it("does not prompt after the operation has been aborted", async () => { + const app = mount(); + app.controller.abort(); + await app.result(); + expect(app.ui.confirm).not.toHaveBeenCalled(); + }); + + it("does not stage a command if the operation is aborted during confirmation", async () => { + const app = mount(); + app.ui.confirm.mockImplementationOnce(async () => { + app.controller.abort(); + return true; + }); + await app.result(); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + }); + + it("uses the execute signal rather than an unrelated active context signal", async () => { + const app = mount(); + const operation = new AbortController(); + operation.abort(); + const result = await app.result("touch /restricted/file", operation.signal); + expect(result.content).toEqual([{ type: "text", text: "Command handoff cancelled." }]); + expect(app.ui.confirm).not.toHaveBeenCalled(); + expect(app.controller.signal.aborted).toBe(false); + }); + + it("honors execute-signal cancellation during confirmation", async () => { + const app = mount(); + const operation = new AbortController(); + app.ui.confirm.mockImplementationOnce(async () => { + operation.abort(); + return true; + }); + const result = await app.result("touch /restricted/file", operation.signal); + expect(app.ui.confirm).toHaveBeenCalledWith(expect.any(String), expect.any(String), { + signal: operation.signal, + }); + expect(result).not.toHaveProperty("terminate"); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(app.ctx.abort).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps a queued request bound to its captured operation signal", async () => { + const app = mount(); + const operation = new AbortController(); + let answer!: (value: boolean) => void; + app.ui.confirm.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + const first = app.result(); + const second = app.result("second command", operation.signal); + await Promise.resolve(); + operation.abort(); + app.ctx.signal = new AbortController().signal; + answer(false); + const [, result] = await Promise.all([first, second]); + expect(result.content).toEqual([{ type: "text", text: "Command handoff cancelled." }]); + expect(app.ui.confirm).toHaveBeenCalledOnce(); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("serializes concurrent requests and keeps the first accepted command", async () => { + const app = mount(); + let answer!: (value: boolean) => void; + app.ui.confirm.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + const first = app.result(); + const second = app.result("second command"); + await Promise.resolve(); + expect(app.ui.confirm).toHaveBeenCalledOnce(); + answer(true); + await Promise.all([first, second]); + expect(app.ui.confirm).toHaveBeenCalledOnce(); + expect(app.editor()).toBe("!touch /restricted/file"); + }); + + it("offers the next concurrent request after No", async () => { + const app = mount(); + app.ui.confirm.mockResolvedValueOnce(false); + await Promise.all([app.result(), app.result("second command")]); + expect(app.ui.confirm).toHaveBeenCalledTimes(2); + expect(app.editor()).toBe("!second command"); + }); + + it("reports UI errors without preventing future handoff attempts", async () => { + const app = mount(); + app.ui.confirm.mockRejectedValueOnce(new Error("dialog failed")); + await expect(app.result()).rejects.toThrow("dialog failed"); + expect(app.ctx.abort).not.toHaveBeenCalled(); + await app.result(); + expect(app.editor()).toBe("!touch /restricted/file"); + }); + + it.each([0, 1])( + "resumes once after a recorded shell result with exit code %s", + async (exitCode) => { + const app = mount(); + await app.result(); + expect(app.controller.signal.aborted).toBe(true); + expect(app.submitShell()).toBeUndefined(); + app.recordShell({ exitCode }); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).toHaveBeenCalledExactlyOnceWith( + { + customType: "command-handoff-resume", + content: expect.stringContaining("using its recorded result"), + display: false, + }, + { triggerTurn: true }, + ); + expect(vi.getTimerCount()).toBe(0); + expect(app.unsubscribe).toHaveBeenCalledOnce(); + app.recordShell(); + await vi.advanceTimersByTimeAsync(1_000); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }, + ); + + it("waits for a new recorded result and does not repeatedly scan unchanged history", async () => { + const app = mount(); + app.recordShell(); + await app.result(); + const getBranch = vi.spyOn(app.ctx.sessionManager, "getBranch"); + await vi.advanceTimersByTimeAsync(5_000); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(getBranch).not.toHaveBeenCalled(); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(getBranch).toHaveBeenCalledOnce(); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }); + + it("matches Pi's outer whitespace trimming without changing the staged command", async () => { + const app = mount(); + const command = " printf '%s\\n' 'hello world' &&\n touch /restricted/file "; + await app.result(command); + expect(app.editor()).toBe(`!${command}`); + expect(app.submitShell()).toBeUndefined(); + app.recordShell({ command: command.trim() }); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }); + + it("keeps watching through the original abort and waits for the agent to become idle", async () => { + const app = mount(); + await app.result(); + app.ctx.isIdle.mockReturnValue(false); + app.ctx.sessionManager.appendCustomEntry("original-run-ending", {}); + app.emit("agent_end"); + app.emit("agent_settled"); + await vi.advanceTimersByTimeAsync(250); + app.recordShell(); + await vi.advanceTimersByTimeAsync(500); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + app.ctx.isIdle.mockReturnValue(true); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }); + + it.each([{ command: "another command" }, { cancelled: true }, { excludeFromContext: true }])( + "disarms on an unrelated, cancelled, or private shell result: %j", + async (result) => { + const app = mount(); + await app.result(); + app.recordShell(result); + await vi.advanceTimersByTimeAsync(250); + expect(vi.getTimerCount()).toBe(0); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + }, + ); + + it.each(["!another command", "!touch /restricted/file", "!!touch /restricted/file"])( + "disarms on a second submission before that command finishes: %s", + async (text) => { + const app = mount(); + await app.result(); + app.submitShell(); + app.recordShell(); + expect(app.submitShell(text)).toBeUndefined(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(app.unsubscribe).toHaveBeenCalledOnce(); + }, + ); + + it.each(["!edited command", "!!touch /restricted/file"])( + "disarms on an edited submission even between polling ticks: %s", + async (text) => { + const app = mount(); + await app.result(); + expect(app.submitShell(text)).toBeUndefined(); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it.each(["confirmation", "command"])("ignores the %s Enter key release", async (phase) => { + const app = mount(); + await app.result(); + if (phase === "confirmation") app.terminalInput("\u001b[13;1:3u"); + expect(app.terminalInput("\u001b[13;1u")).toBeUndefined(); + app.ui.setEditorText(""); + if (phase === "command") app.terminalInput("\u001b[13;1:3u"); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }); + + it("leaves non-submit keys alone", async () => { + const app = mount(); + await app.result(); + expect(app.terminalInput("\u001b[A")).toBeUndefined(); + expect(vi.getTimerCount()).toBe(1); + app.submitShell(); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).toHaveBeenCalledOnce(); + }); + + it("does not resume if another shell result follows before polling", async () => { + const app = mount(); + await app.result(); + app.recordShell(); + app.recordShell({ command: "another command" }); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["edited command", "new draft", "queued message"])( + "preserves %s and cancels auto-resume", + async (change) => { + const app = mount(); + await app.result(); + if (change !== "edited command") app.recordShell(); + if (change === "queued message") app.ctx.hasPendingMessages.mockReturnValue(true); + else app.ui.setEditorText(change === "edited command" ? "!edited command" : "new draft"); + app.ui.setEditorText.mockClear(); + const draft = app.editor(); + await vi.advanceTimersByTimeAsync(250); + expect(app.ui.setEditorText).not.toHaveBeenCalled(); + expect(app.editor()).toBe(draft); + expect(vi.getTimerCount()).toBe(0); + app.ctx.hasPendingMessages.mockReturnValue(false); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + }, + ); + + it.each([ + "input", + "before_agent_start", + "session_before_switch", + "session_before_fork", + "session_before_tree", + "session_start", + "session_shutdown", + ])("stops watching on %s", async (event) => { + const app = mount(); + await app.result(); + expect(vi.getTimerCount()).toBe(1); + app.emit(event); + app.recordShell(); + const getLeaf = vi.spyOn(app.ctx.sessionManager, "getLeafId"); + await vi.advanceTimersByTimeAsync(1_000); + expect(vi.getTimerCount()).toBe(0); + expect(getLeaf).not.toHaveBeenCalled(); + expect(app.unsubscribe).toHaveBeenCalledOnce(); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + }); + + it("does not resume in another session even if the switch event is missed", async () => { + const app = mount(); + await app.result(); + app.ctx.sessionManager.newSession(); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not resume on another branch even if the navigation event is missed", async () => { + const app = mount(); + const root = app.ctx.sessionManager.appendCustomEntry("root", {}); + app.ctx.sessionManager.appendCustomEntry("original-branch", {}); + await app.result(); + app.ctx.sessionManager.branch(root); + app.recordShell(); + await vi.advanceTimersByTimeAsync(250); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["ordinary shell", "declined", "aborted", "queued", "UI failure"])( + "never watches without an accepted handoff: %s", + async (scenario) => { + const app = mount(); + if (scenario === "declined") app.ui.confirm.mockResolvedValueOnce(false); + if (scenario === "aborted") app.controller.abort(); + if (scenario === "queued") app.ctx.hasPendingMessages.mockReturnValue(true); + if (scenario === "UI failure") + app.ui.confirm.mockRejectedValueOnce(new Error("dialog failed")); + if (scenario !== "ordinary shell") { + if (scenario === "UI failure") await expect(app.result()).rejects.toThrow("dialog failed"); + else await app.result(); + } + app.recordShell(); + await vi.advanceTimersByTimeAsync(1_000); + expect(app.api.sendMessage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); +});