Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
44 changes: 44 additions & 0 deletions extensions/command-handoff/README.md
Original file line number Diff line number Diff line change
@@ -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.
175 changes: 175 additions & 0 deletions extensions/command-handoff/main.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
19 changes: 19 additions & 0 deletions skills/permission-handoff/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading