> pi can create hooks. Ask it to build one for your use case. # Hooks Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more. **Key capabilities:** - **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify) - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` - **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()` - **Event interception** - Block or modify tool calls, inject context, customize compaction - **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()` **Example use cases:** - Permission gates (confirm before `rm -rf`, `sudo`, etc.) - Git checkpointing (stash at each turn, restore on `/branch`) - Path protection (block writes to `.env`, `node_modules/`) - External integrations (file watchers, webhooks, CI triggers) - Interactive tools (games, wizards, custom dialogs) See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI. ## Quick Start Create `~/.pi/agent/hooks/my-hook.ts`: ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { ctx.ui.notify("Hook loaded!", "info"); }); pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); if (!ok) return { block: true, reason: "Blocked by user" }; } }); } ``` Test with `--hook` flag: ```bash pi --hook ./my-hook.ts ``` ## Hook Locations Hooks are auto-discovered from: | Location | Scope | |----------|-------| | `~/.pi/agent/hooks/*.ts` | Global (all projects) | | `.pi/hooks/*.ts` | Project-local | Additional paths via `settings.json`: ```json { "hooks": ["/path/to/hook.ts"] } ``` ## Available Imports | Package | Purpose | |---------|---------| | `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) | | `@mariozechner/pi-coding-agent` | Additional types if needed | | `@mariozechner/pi-ai` | AI utilities | | `@mariozechner/pi-tui` | TUI components | Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. ## Writing a Hook A hook exports a default function that receives `HookAPI`: ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { // Subscribe to events pi.on("event_name", async (event, ctx) => { // Handle event }); } ``` Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. ## Events ### Lifecycle Overview ``` pi starts │ └─► session_start │ ▼ user sends prompt ─────────────────────────────────────────┐ │ │ ├─► before_agent_start (can inject message, append to system prompt) │ ├─► agent_start │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ │ ├─► context (can modify messages) │ │ │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_call (can block) │ │ │ │ │ tool executes │ │ │ │ └─► tool_result (can modify) │ │ │ │ │ │ │ └─► turn_end │ │ │ │ └─► agent_end │ │ user sends another prompt ◄────────────────────────────────┘ /new (new session) or /resume (switch session) ├─► session_before_switch (can cancel, has reason: "new" | "resume") └─► session_switch (has reason: "new" | "resume") /branch ├─► session_before_branch (can cancel) └─► session_branch /compact or auto-compaction ├─► session_before_compact (can cancel or customize) └─► session_compact /tree navigation ├─► session_before_tree (can cancel or customize) └─► session_tree exit (Ctrl+C, Ctrl+D) └─► session_shutdown ``` ### Session Events #### session_start Fired on initial session load. ```typescript pi.on("session_start", async (_event, ctx) => { ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); }); ``` #### session_before_switch / session_switch Fired when starting a new session (`/new`) or switching sessions (`/resume`). ```typescript pi.on("session_before_switch", async (event, ctx) => { // event.reason - "new" (starting fresh) or "resume" (switching to existing) // event.targetSessionFile - session we're switching to (only for "resume") if (event.reason === "new") { const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); if (!ok) return { cancel: true }; } return { cancel: true }; // Cancel the switch/new }); pi.on("session_switch", async (event, ctx) => { // event.reason - "new" or "resume" // event.previousSessionFile - session we came from }); ``` #### session_before_branch / session_branch Fired when branching via `/branch`. ```typescript pi.on("session_before_branch", async (event, ctx) => { // event.entryId - ID of the entry being branched from return { cancel: true }; // Cancel branch // OR return { skipConversationRestore: true }; // Branch but don't rewind messages }); pi.on("session_branch", async (event, ctx) => { // event.previousSessionFile - previous session file }); ``` The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately. #### session_before_compact / session_compact Fired on compaction. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, signal } = event; // Cancel: return { cancel: true }; // Custom summary: return { compaction: { summary: "...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, } }; }); pi.on("session_compact", async (event, ctx) => { // event.compactionEntry - the saved compaction // event.fromHook - whether hook provided it }); ``` #### session_before_tree / session_tree Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize // preparation.userWantsSummary - whether user chose to summarize return { cancel: true }; // OR provide custom summary (only used if userWantsSummary is true): return { summary: { summary: "...", details: {} } }; }); pi.on("session_tree", async (event, ctx) => { // event.newLeafId, oldLeafId, summaryEntry, fromHook }); ``` #### session_shutdown Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). ```typescript pi.on("session_shutdown", async (_event, ctx) => { // Cleanup, save state, etc. }); ``` ### Agent Events #### before_agent_start Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt. ```typescript pi.on("before_agent_start", async (event, ctx) => { // event.prompt - user's prompt text // event.images - attached images (if any) return { // Inject a persistent message (stored in session, sent to LLM) message: { customType: "my-hook", content: "Additional context for the LLM", display: true, // Show in TUI }, // Append to system prompt for this turn only systemPromptAppend: "Extra instructions for this turn...", }; }); ``` **message**: Persisted as `CustomMessageEntry` and sent to the LLM. Multiple hooks can each return a message; all are injected in order. **systemPromptAppend**: Appended to the base system prompt for this agent run only. Multiple hooks can each return `systemPromptAppend` strings, which are concatenated. This is useful for dynamic instructions based on hook state (e.g., plan mode, persona toggles). See [examples/hooks/pirate.ts](../examples/hooks/pirate.ts) for an example using `systemPromptAppend`. #### agent_start / agent_end Fired once per user prompt. ```typescript pi.on("agent_start", async (_event, ctx) => {}); pi.on("agent_end", async (event, ctx) => { // event.messages - messages from this prompt }); ``` #### turn_start / turn_end Fired for each turn (one LLM response + tool calls). ```typescript pi.on("turn_start", async (event, ctx) => { // event.turnIndex, event.timestamp }); pi.on("turn_end", async (event, ctx) => { // event.turnIndex // event.message - assistant's response // event.toolResults - tool results from this turn }); ``` #### context Fired before each LLM call. Modify messages non-destructively (session unchanged). ```typescript pi.on("context", async (event, ctx) => { // event.messages - deep copy, safe to modify // Filter or transform messages const filtered = event.messages.filter(m => !shouldPrune(m)); return { messages: filtered }; }); ``` ### Tool Events #### tool_call Fired before tool executes. **Can block.** ```typescript pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash", "read", "write", "edit", etc. // event.toolCallId // event.input - tool parameters if (shouldBlock(event)) { return { block: true, reason: "Not allowed" }; } }); ``` Tool inputs: - `bash`: `{ command, timeout? }` - `read`: `{ path, offset?, limit? }` - `write`: `{ path, content }` - `edit`: `{ path, oldText, newText }` - `ls`: `{ path?, limit? }` - `find`: `{ pattern, path?, limit? }` - `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }` #### tool_result Fired after tool executes (including errors). **Can modify result.** Check `event.isError` to distinguish successful executions from failures. ```typescript pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input // event.content - array of TextContent | ImageContent // event.details - tool-specific (see below) // event.isError - true if the tool threw an error if (event.isError) { // Handle error case } // Modify result: return { content: [...], details: {...}, isError: false }; }); ``` Use type guards for typed details: ```typescript import { isBashToolResult } from "@mariozechner/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { if (isBashToolResult(event)) { // event.details is BashToolDetails | undefined if (event.details?.truncation?.truncated) { // Full output at event.details.fullOutputPath } } }); ``` Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`. ## HookContext Every handler receives `ctx: HookContext`: ### ctx.ui UI methods for user interaction. Hooks can prompt users and even render custom TUI components. **Built-in dialogs:** ```typescript // Select from options const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // Returns selected string or undefined if cancelled // Confirm dialog const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // Returns true or false // Text input (single line) const name = await ctx.ui.input("Name:", "placeholder"); // Returns string or undefined if cancelled // Multi-line editor (with Ctrl+G for external editor) const text = await ctx.ui.editor("Edit prompt:", "prefilled text"); // Returns edited text or undefined if cancelled (Escape) // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" // Set status text in footer (persistent until cleared) ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status ctx.ui.setStatus("my-hook", undefined); // Clear status // Set a multi-line widget (displayed above editor, below "Working..." indicator) ctx.ui.setWidget("my-todos", [ theme.fg("accent", "Plan Progress:"), theme.fg("success", "☑ ") + theme.fg("muted", theme.strikethrough("Read files")), theme.fg("muted", "☐ ") + "Modify code", theme.fg("muted", "☐ ") + "Run tests", ]); ctx.ui.setWidget("my-todos", undefined); // Clear widget // Set the core input editor text (pre-fill prompts, generated content) ctx.ui.setEditorText("Generated prompt text here..."); // Get current editor text const currentText = ctx.ui.getEditorText(); ``` **Status text notes:** - Multiple hooks can set their own status using unique keys - Statuses are displayed on a single line in the footer, sorted alphabetically by key - Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width - Use `ctx.ui.theme` to style status text with theme colors (see below) **Widget notes:** - Widgets are multi-line displays shown above the editor (below "Working..." indicator) - Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically) - `setWidget()` accepts either a string array or a component factory function - Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`) - **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets. **Custom widget components:** For more complex widgets, pass a factory function to `setWidget()`: ```typescript ctx.ui.setWidget("my-widget", (tui, theme) => { // Return any Component that implements render(width): string[] return new MyCustomComponent(tui, theme); }); // Clear the widget ctx.ui.setWidget("my-widget", undefined); ``` Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor. **Styling with theme colors:** Use `ctx.ui.theme` to apply consistent colors that respect the user's theme: ```typescript const theme = ctx.ui.theme; // Foreground colors ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + theme.fg("dim", " Ready")); ctx.ui.setStatus("my-hook", theme.fg("error", "✗") + theme.fg("dim", " Failed")); ctx.ui.setStatus("my-hook", theme.fg("accent", "●") + theme.fg("dim", " Working...")); // Available fg colors: accent, success, error, warning, muted, dim, text, and more // See docs/theme.md for the full list of theme colors ``` See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a complete example. **Custom components:** Show a custom TUI component with keyboard focus: ```typescript import { BorderedLoader } from "@mariozechner/pi-coding-agent"; const result = await ctx.ui.custom((tui, theme, done) => { const loader = new BorderedLoader(tui, theme, "Working..."); loader.onAbort = () => done(null); doWork(loader.signal).then(done).catch(() => done(null)); return loader; // Return the component directly, do NOT wrap in Box/Container }); ``` **Important:** Return your component directly from the callback. Do not wrap it in a `Box` or `Container`, as this breaks input handling. Your component can: - Implement `handleInput(data: string)` to receive keyboard input - Implement `render(width: number): string[]` to render lines - Implement `invalidate()` to clear cached render - Implement `dispose()` for cleanup when closed - Call `tui.requestRender()` to trigger re-render - Call `done(result)` when done to restore normal UI See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API. ### ctx.hasUI `false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`: ```typescript if (ctx.hasUI) { const choice = await ctx.ui.select(...); } else { // Default behavior } ``` ### ctx.cwd Current working directory. ### ctx.sessionManager Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts). ```typescript // Session info ctx.sessionManager.getCwd() // Working directory ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions) ctx.sessionManager.getSessionId() // Current session ID ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session) // Entries ctx.sessionManager.getEntries() // All entries (excludes header) ctx.sessionManager.getHeader() // Session header entry ctx.sessionManager.getEntry(id) // Specific entry by ID ctx.sessionManager.getLabel(id) // Entry label (if any) // Tree navigation ctx.sessionManager.getBranch() // Current branch (root to leaf) ctx.sessionManager.getBranch(leafId) // Specific branch ctx.sessionManager.getTree() // Full tree structure ctx.sessionManager.getLeafId() // Current leaf entry ID ctx.sessionManager.getLeafEntry() // Current leaf entry ``` Use `pi.sendMessage()` or `pi.appendEntry()` for writes. ### ctx.modelRegistry Access to models and API keys: ```typescript // Get API key for a model const apiKey = await ctx.modelRegistry.getApiKey(model); // Get available models const models = ctx.modelRegistry.getAvailableModels(); ``` ### ctx.model Current model, or `undefined` if none selected yet. Use for LLM calls in hooks: ```typescript if (ctx.model) { const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); // Use with @mariozechner/pi-ai complete() } ``` ### ctx.isIdle() Returns `true` if the agent is not currently streaming: ```typescript if (ctx.isIdle()) { // Agent is not processing } ``` ### ctx.abort() Abort the current agent operation (fire-and-forget, does not wait): ```typescript await ctx.abort(); ``` ### ctx.hasPendingMessages() Check if there are messages pending (user typed while agent was streaming): ```typescript if (ctx.hasPendingMessages()) { // Skip interactive prompt, let pending messages take over return; } ``` ## HookCommandContext (Slash Commands Only) Slash command handlers receive `HookCommandContext`, which extends `HookContext` with session control methods. These methods are only safe in user-initiated commands because they can cause deadlocks if called from event handlers (which run inside the agent loop). ### ctx.waitForIdle() Wait for the agent to finish streaming: ```typescript await ctx.waitForIdle(); // Agent is now idle ``` ### ctx.newSession(options?) Create a new session, optionally with initialization: ```typescript const result = await ctx.newSession({ parentSession: ctx.sessionManager.getSessionFile(), // Track lineage setup: async (sm) => { // Initialize the new session sm.appendMessage({ role: "user", content: [{ type: "text", text: "Context from previous session..." }], timestamp: Date.now(), }); }, }); if (result.cancelled) { // A hook cancelled the new session } ``` ### ctx.branch(entryId) Branch from a specific entry, creating a new session file: ```typescript const result = await ctx.branch("entry-id-123"); if (!result.cancelled) { // Now in the branched session } ``` ### ctx.navigateTree(targetId, options?) Navigate to a different point in the session tree: ```typescript const result = await ctx.navigateTree("entry-id-456", { summarize: true, // Summarize the abandoned branch }); ``` ## HookAPI Methods ### pi.on(event, handler) Subscribe to events. See [Events](#events) for all event types. ### pi.sendMessage(message, options?) Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context. ```typescript pi.sendMessage({ customType: "my-hook", // Your hook's identifier content: "Message text", // string or (TextContent | ImageContent)[] display: true, // Show in TUI details: { ... }, // Optional metadata (not sent to LLM) }, { triggerTurn: true, // If true and agent is idle, triggers LLM response deliverAs: "steer", // "steer" (default) or "followUp" when agent is streaming }); ``` **Storage and timing:** - The message is appended to the session file immediately as a `CustomMessageEntry` - If the agent is currently streaming: - `deliverAs: "steer"` (default): Delivered after current tool execution, interrupts remaining tools - `deliverAs: "followUp"`: Delivered only after agent finishes all work - If `triggerTurn` is true and the agent is idle, a new agent loop starts **LLM context:** - `CustomMessageEntry` is converted to a user message when building context for the LLM - Only `content` is sent to the LLM; `details` is for rendering/state only **TUI display:** - If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) - If `display: false`, the message is hidden from the TUI but still sent to the LLM - Use `pi.registerMessageRenderer()` to customize how your messages render (see below) ### pi.appendEntry(customType, data?) Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context). ```typescript // Save state pi.appendEntry("my-hook-state", { count: 42 }); // Restore on reload pi.on("session_start", async (_event, ctx) => { for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === "my-hook-state") { // Reconstruct from entry.data } } }); ``` ### pi.registerCommand(name, options) Register a custom slash command: ```typescript pi.registerCommand("stats", { description: "Show session statistics", handler: async (args, ctx) => { // args = everything after /stats const count = ctx.sessionManager.getEntries().length; ctx.ui.notify(`${count} entries`, "info"); } }); ``` For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts). To trigger LLM after command, call `pi.sendMessage(..., { triggerTurn: true })`. ### pi.registerMessageRenderer(customType, renderer) Register a custom TUI renderer for `CustomMessageEntry` messages with your `customType`. Without a custom renderer, messages display with default purple styling showing the content as-is. ```typescript import { Text } from "@mariozechner/pi-tui"; pi.registerMessageRenderer("my-hook", (message, options, theme) => { // message.content - the message content (string or content array) // message.details - your custom metadata // options.expanded - true if user pressed Ctrl+O const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `); const text = typeof message.content === "string" ? message.content : message.content.map(c => c.type === "text" ? c.text : "[image]").join(""); return new Text(prefix + theme.fg("text", text), 0, 0); }); ``` **Renderer signature:** ```typescript type HookMessageRenderer = ( message: CustomMessageEntry, options: { expanded: boolean }, theme: Theme ) => Component | null; ``` Return `null` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details. ### pi.exec(command, args, options?) Execute a shell command: ```typescript const result = await pi.exec("git", ["status"], { signal, // AbortSignal timeout, // Milliseconds }); // result.stdout, result.stderr, result.code, result.killed ``` ### pi.getActiveTools() Get the names of currently active tools: ```typescript const toolNames = pi.getActiveTools(); // ["read", "bash", "edit", "write"] ``` ### pi.getAllTools() Get all configured tools (built-in via --tools or default, plus custom tools): ```typescript const allTools = pi.getAllTools(); // ["read", "bash", "edit", "write", "my-custom-tool"] ``` ### pi.setActiveTools(toolNames) Set the active tools by name. Changes take effect on the next agent turn. Note: This will invalidate prompt caching for the next request. ```typescript // Switch to read-only mode (plan mode) pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); // Restore full access pi.setActiveTools(["read", "bash", "edit", "write"]); ``` Both built-in and custom tools can be enabled/disabled. Unknown tool names are ignored. ### pi.registerFlag(name, options) Register a CLI flag for this hook. Flag values are accessible via `pi.getFlag()`. ```typescript pi.registerFlag("plan", { description: "Start in plan mode (read-only)", type: "boolean", // or "string" default: false, }); ``` ### pi.getFlag(name) Get the value of a CLI flag registered by this hook. ```typescript if (pi.getFlag("plan") === true) { // plan mode enabled via --plan flag } ``` ### pi.registerShortcut(shortcut, options) Register a keyboard shortcut for this hook. The handler is called when the shortcut is pressed. ```typescript pi.registerShortcut("shift+p", { description: "Toggle plan mode", handler: async (ctx) => { // toggle mode ctx.ui.notify("Plan mode toggled"); }, }); ``` Shortcut format: `modifier+key` where modifier can be `shift`, `ctrl`, `alt`, or combinations like `ctrl+shift`. ## Examples ### Permission Gate ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i]; pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return; const cmd = event.input.command as string; if (dangerous.some(p => p.test(cmd))) { if (!ctx.hasUI) { return { block: true, reason: "Dangerous (no UI)" }; } const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`); if (!ok) return { block: true, reason: "Blocked by user" }; } }); } ``` ### Protected Paths ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "write" && event.toolName !== "edit") return; const path = event.input.path as string; if (protectedPaths.some(p => path.includes(p))) { ctx.ui.notify(`Blocked: ${path}`, "warning"); return { block: true, reason: `Protected: ${path}` }; } }); } ``` ### Git Checkpoint ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const checkpoints = new Map(); let currentEntryId: string | undefined; pi.on("tool_result", async (_event, ctx) => { const leaf = ctx.sessionManager.getLeafEntry(); if (leaf) currentEntryId = leaf.id; }); pi.on("turn_start", async () => { const { stdout } = await pi.exec("git", ["stash", "create"]); if (stdout.trim() && currentEntryId) { checkpoints.set(currentEntryId, stdout.trim()); } }); pi.on("session_before_branch", async (event, ctx) => { const ref = checkpoints.get(event.entryId); if (!ref || !ctx.hasUI) return; const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?"); if (ok) { await pi.exec("git", ["stash", "apply", ref]); ctx.ui.notify("Code restored", "info"); } }); pi.on("agent_end", () => checkpoints.clear()); } ``` ### Custom Command See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence. ## Mode Behavior | Mode | UI Methods | Notes | |------|-----------|-------| | Interactive | Full TUI | Normal operation | | RPC | JSON protocol | Host handles UI | | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`. ## Error Handling - Hook errors are logged, agent continues - `tool_call` errors block the tool (fail-safe) - Errors display in UI with hook path and message - If a hook hangs, use Ctrl+C to abort ## Debugging 1. Open VS Code in hooks directory 2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal") 3. Set breakpoints 4. Run `pi --hook ./my-hook.ts`