From b29bf6db2d6d5ffd1b0af8c37a1f38dbd4afb47a Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Tue, 5 May 2026 23:24:31 +0200 Subject: [PATCH] add confirm-destructive, git-checkpoint, notify extensions --- AGENTS.md | 44 ++++++++++++- README.md | 52 ++++++++++++++- extensions/confirm-destructive.ts | 106 ++++++++++++++++++++++++++++++ extensions/git-checkpoint.ts | 76 +++++++++++++++++++++ extensions/notify.ts | 78 ++++++++++++++++++++++ 5 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 extensions/confirm-destructive.ts create mode 100644 extensions/git-checkpoint.ts create mode 100644 extensions/notify.ts diff --git a/AGENTS.md b/AGENTS.md index 5d5155b..97f67ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ This file is for agents modifying the repo. ``` extensions/ ssh-controlmaster.ts # ControlMaster SSH remote execution (see below) + confirm-destructive.ts # Confirm before dangerous bash commands and session actions + git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork + notify.ts # Native terminal notification when agent finishes install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative README.md # User-facing docs. @@ -115,7 +118,46 @@ on a remote machine via SSH when `--ssh user@host` is passed. `--ssh-ask-pass` is silently ignored — the system master handles auth independently and the socket is just reused. -## Testing +### `confirm-destructive.ts` + +Always-on (no flag). Two layers: + +1. **Bash gate** — intercepts `tool_call` for `bash` and checks the command + against a pattern list. On match, shows a `select` dialog. Blocks in + non-interactive mode by default. + Patterns: recursive `rm`, `sudo`, `chmod/chown 777`, `dd if=`, `mkfs`, + `git push --force`, writes to `/dev/*`, `truncate --size 0`. + +2. **Session gate** — hooks `session_before_switch` and `session_before_fork`. + Confirms before `/new` (clear) and `/resume` (switch, only if there are + unsaved messages). Always confirms before `/fork`. + +When adding new patterns, add to the `DANGEROUS` array at the top of the file. +Each entry has a `pattern` (RegExp) and a `label` shown in the dialog. + +### `git-checkpoint.ts` + +Always-on. Silently skips when the `cwd` is not inside a git repo. + +- At `turn_start`: runs `git stash create` (non-destructive — creates a stash + object without touching the working tree). Stores the stash ref keyed to + the current session entry ID. Empty output (nothing to stash) is silently + ignored. +- At `session_before_fork`: if a checkpoint exists for the selected entry, + offers to `git stash apply` it. +- Status bar shows `⎇ N checkpoints` when checkpoints are present. +- Checkpoints are in-memory only — they do not survive a pi restart, but stash + objects remain in the git repo and can be applied manually. + +### `notify.ts` + +Always-on. Records `agent_start` timestamp; on `agent_end` checks if elapsed +time exceeds `--notify-min-secs` (default 8). Silently skips short responses. + +Terminal detection order: `KITTY_WINDOW_ID` → OSC 99 (Kitty) → +`WT_SESSION` → Windows toast → OSC 777 (iTerm2, WezTerm, Ghostty). + +Notification text: `Pi — Done (Ns)` where N is the rounded elapsed seconds. No framework. Manual: diff --git a/README.md b/README.md index a32ea1d..548e036 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,57 @@ The status bar shows `⚡ own master` or `⚡ system master` so you can see whic --- -## Adding a new extension +### `confirm-destructive.ts` + +Confirmation gates for dangerous bash commands and destructive session actions. Always-on — no flag needed. + +**Bash commands intercepted:** +- Recursive removes (`rm -rf`, `rm -r`, etc.) +- Any `sudo` command +- `chmod`/`chown 777` +- `dd if=` (disk operations) +- `mkfs` (format filesystem) +- `git push --force` / `git push -f` +- Writes to `/dev/*` +- `truncate --size 0` + +In non-interactive mode (e.g. `pi -p`) dangerous commands are blocked outright rather than prompted. + +**Session actions gated:** +- `/new` — confirms before clearing the session +- `/resume` — confirms before switching away if the current session has messages +- `/fork` — always confirms + +--- + +### `git-checkpoint.ts` + +Creates a git stash checkpoint at the start of each turn, keyed to the session entry ID. If you `/fork` from a past entry, you're offered the option to restore the code to that point. + +Silently skips when the working directory isn't inside a git repo, or when there are no changes to stash. Status bar shows `⎇ N checkpoints` during active sessions. + +**Notes:** +- Uses `git stash create` — non-destructive, doesn't touch your working tree +- Stash objects persist in the git repo even after pi exits, so you can apply them manually with `git stash apply ` if needed +- Checkpoints are in-memory per session — the entry→ref mapping is lost on restart, but the underlying stash objects remain + +--- + +### `notify.ts` + +Sends a native terminal notification when the agent finishes and is waiting for input. Only fires when the agent ran for longer than the threshold (default 8 seconds) — quick responses are silently skipped. + +**Terminal support:** +- Kitty (`KITTY_WINDOW_ID`) → OSC 99 +- Windows Terminal / WSL (`WT_SESSION`) → Windows toast +- Everything else (iTerm2, WezTerm, Ghostty) → OSC 777 + +**Flag:** + +```bash +pi --notify-min-secs 15 # only notify for tasks over 15 seconds +pi --notify-min-secs 0 # notify on every agent completion +``` 1. Drop a `.ts` file into `extensions/` 2. Re-run `./install.sh` — it picks up the new file and symlinks it diff --git a/extensions/confirm-destructive.ts b/extensions/confirm-destructive.ts new file mode 100644 index 0000000..789d20c --- /dev/null +++ b/extensions/confirm-destructive.ts @@ -0,0 +1,106 @@ +/** + * Confirm Destructive Actions + * + * Two layers of protection: + * + * 1. Bash commands — intercepts tool_call for bash and prompts before running + * patterns that are hard or impossible to undo. + * + * 2. Session actions — prompts before /new (clear), /resume (switch), and + * /fork so you don't accidentally throw away work. + */ + +import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; + +// ── Dangerous bash patterns ─────────────────────────────────────────────────── + +const DANGEROUS: { pattern: RegExp; label: string }[] = [ + { pattern: /\brm\s+(-[a-z]*r[a-z]*f?[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\b/i, label: "recursive remove" }, + { pattern: /\bsudo\b/i, label: "sudo" }, + { pattern: /\b(chmod|chown)\b.*\b777\b/i, label: "chmod/chown 777" }, + { pattern: /\bdd\b.*\bif=/i, label: "dd (disk operation)" }, + { pattern: /\bmkfs\b/i, label: "mkfs (format)" }, + { pattern: /\bgit\s+push\b.*\s(-f|--force)\b/i, label: "git force push" }, + { pattern: />\s*\/dev\//i, label: "write to device" }, + { pattern: /\btruncate\b.*--size\s+0/i, label: "truncate to zero" }, +]; + +function matchesDangerous(command: string): string | null { + for (const { pattern, label } of DANGEROUS) { + if (pattern.test(command)) return label; + } + return null; +} + +// ── Extension ───────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + + // ── 1. Dangerous bash commands ───────────────────────────────────────────── + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return; + + const command = event.input.command as string; + const match = matchesDangerous(command); + if (!match) return; + + if (!ctx.hasUI) { + return { block: true, reason: `Dangerous command blocked (${match}) — no UI for confirmation` }; + } + + const choice = await ctx.ui.select( + `⚠️ ${match}\n\n ${command.slice(0, 200)}${command.length > 200 ? "…" : ""}\n\nAllow?`, + ["Yes, run it", "No, block it"], + ); + + if (choice !== "Yes, run it") { + ctx.ui.notify("Command blocked", "info"); + return { block: true, reason: "Blocked by user" }; + } + }); + + // ── 2. Session clear / switch ────────────────────────────────────────────── + + pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => { + if (!ctx.hasUI) return; + + if (event.reason === "new") { + const ok = await ctx.ui.confirm("Clear session?", "All messages in this session will be deleted."); + if (!ok) { + ctx.ui.notify("Clear cancelled", "info"); + return { cancel: true }; + } + return; + } + + // resume — only confirm if there's unsaved user work + const entries = ctx.sessionManager.getEntries(); + const hasUnsaved = entries.some( + (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", + ); + if (!hasUnsaved) return; + + const ok = await ctx.ui.confirm("Switch session?", "Current session has messages. Switch anyway?"); + if (!ok) { + ctx.ui.notify("Switch cancelled", "info"); + return { cancel: true }; + } + }); + + // ── 3. Fork ──────────────────────────────────────────────────────────────── + + pi.on("session_before_fork", async (event, ctx) => { + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select( + `Fork from entry ${event.entryId.slice(0, 8)}?`, + ["Yes, create fork", "No, stay here"], + ); + + if (choice !== "Yes, create fork") { + ctx.ui.notify("Fork cancelled", "info"); + return { cancel: true }; + } + }); +} diff --git a/extensions/git-checkpoint.ts b/extensions/git-checkpoint.ts new file mode 100644 index 0000000..36fbcd3 --- /dev/null +++ b/extensions/git-checkpoint.ts @@ -0,0 +1,76 @@ +/** + * Git Checkpoint Extension + * + * Creates a git stash checkpoint at the start of each turn, keyed to the + * session entry ID. If you /fork from a past entry, you're offered the option + * to restore the code state from that point. + * + * Silently skips turns where the working directory isn't inside a git repo or + * where there are no changes to stash. + * + * Status bar shows the number of checkpoints saved in the current session. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // entryId → stash ref (e.g. "refs/stash" or a full sha) + const checkpoints = new Map(); + + async function inGitRepo(): Promise { + try { + const result = await pi.exec("git", ["rev-parse", "--git-dir"]); + return result.exitCode === 0; + } catch { + return false; + } + } + + function updateStatus(ctx: { ui: { setStatus: (id: string, text: string) => void } }): void { + const n = checkpoints.size; + if (n > 0) { + ctx.ui.setStatus("git-checkpoint", `⎇ ${n} checkpoint${n === 1 ? "" : "s"}`); + } else { + ctx.ui.setStatus("git-checkpoint", ""); + } + } + + pi.on("turn_start", async (_event, ctx) => { + if (!(await inGitRepo())) return; + + // Capture the current entry ID at turn start — this is the entry the + // user will fork from if they want to restore code to this point. + const entryId = ctx.sessionManager.getLeafId(); + if (!entryId) return; + + // git stash create makes a stash object without touching the working tree. + // Returns the stash ref on stdout, or empty string if nothing to stash. + const result = await pi.exec("git", ["stash", "create"]); + const ref = result.stdout.trim(); + + if (ref) { + checkpoints.set(entryId, ref); + updateStatus(ctx); + } + }); + + pi.on("session_before_fork", async (event, ctx) => { + const ref = checkpoints.get(event.entryId); + if (!ref) return; + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select( + "Restore code to this checkpoint?", + ["Yes, restore code state", "No, keep current code"], + ); + + if (choice?.startsWith("Yes")) { + await pi.exec("git", ["stash", "apply", ref]); + ctx.ui.notify("Code restored to checkpoint", "success"); + } + }); + + pi.on("session_shutdown", () => { + checkpoints.clear(); + }); +} diff --git a/extensions/notify.ts b/extensions/notify.ts new file mode 100644 index 0000000..4ffa0eb --- /dev/null +++ b/extensions/notify.ts @@ -0,0 +1,78 @@ +/** + * Notify Extension + * + * Sends a native notification when the pi agent finishes and is waiting for + * input. Useful when you step away during a long task. + * + * Only fires when the agent ran for longer than --notify-min-secs (default 8). + * Short responses are silently skipped to avoid notification noise. + * + * Terminal detection (in priority order): + * KITTY_WINDOW_ID → OSC 99 (Kitty) + * WT_SESSION → Windows toast (Windows Terminal / WSL) + * otherwise → OSC 777 (iTerm2, WezTerm, Ghostty, rxvt-unicode) + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +// ── Notification backends ───────────────────────────────────────────────────── + +function notifyKitty(title: string, body: string): void { + // OSC 99: two-part protocol — title first, then body + process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`); + process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`); +} + +function notifyOSC777(title: string, body: string): void { + process.stdout.write(`\x1b]777;notify;${title};${body}\x07`); +} + +function notifyWindows(title: string, body: string): void { + const type = "Windows.UI.Notifications"; + const script = [ + `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime] > $null`, + `$xml = [${type}.ToastNotificationManager]::GetTemplateContent([${type}.ToastTemplateType]::ToastText01)`, + `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`, + `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show([${type}.ToastNotification]::new($xml))`, + ].join("; "); + const { execFile } = require("node:child_process"); + execFile("powershell.exe", ["-NoProfile", "-Command", script]); +} + +function notify(title: string, body: string): void { + if (process.env.KITTY_WINDOW_ID) { + notifyKitty(title, body); + } else if (process.env.WT_SESSION) { + notifyWindows(title, body); + } else { + notifyOSC777(title, body); + } +} + +// ── Extension ───────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + pi.registerFlag("notify-min-secs", { + description: "Minimum agent run time in seconds before a notification fires (default: 8)", + type: "number", + default: 8, + }); + + let startedAt: number | undefined; + + pi.on("agent_start", async () => { + startedAt = Date.now(); + }); + + pi.on("agent_end", async () => { + if (startedAt === undefined) return; + + const elapsed = (Date.now() - startedAt) / 1000; + startedAt = undefined; + + const minSecs = (pi.getFlag("notify-min-secs") as number | undefined) ?? 8; + if (elapsed < minSecs) return; + + notify("Pi", `Done (${Math.round(elapsed)}s)`); + }); +}