31 KiB
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
/mycommandviapi.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/ for working implementations, including a snake game demonstrating custom UI.
Quick Start
Create ~/.pi/agent/hooks/my-hook.ts:
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:
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:
{
"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:
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, 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.
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).
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.
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 for details.
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 for details.
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).
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.
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 for an example using systemPromptAppend.
agent_start / agent_end
Fired once per user prompt.
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).
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).
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.
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.
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:
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:
// 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 terminal window/tab title
ctx.ui.setTitle("pi - my-project");
// 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.themeto 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(includingstrikethrough) - 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.
Terminal title notes:
- Uses OSC escape sequence (works in most modern terminals like iTerm2, Terminal.app, Windows Terminal)
- Useful for showing project name, current task, or session status in the terminal tab/window title
Custom widget components:
For more complex widgets, pass a factory function to setWidget():
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:
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 for a complete example.
Custom components:
Show a custom TUI component with keyboard focus:
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 for a loader pattern and examples/hooks/snake.ts for a game. See 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:
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.
// 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:
// 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:
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:
if (ctx.isIdle()) {
// Agent is not processing
}
ctx.abort()
Abort the current agent operation (fire-and-forget, does not wait):
await ctx.abort();
ctx.hasPendingMessages()
Check if there are messages pending (user typed while agent was streaming):
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:
await ctx.waitForIdle();
// Agent is now idle
ctx.newSession(options?)
Create a new session, optionally with initialization:
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:
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:
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 for all event types.
pi.sendMessage(message, options?)
Inject a message into the session. Creates a CustomMessageEntry that participates in the LLM context.
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", "followUp", or "nextTurn"
});
Delivery modes (deliverAs):
| Mode | When agent is streaming | When agent is idle |
|---|---|---|
"steer" (default) |
Delivered after current tool, interrupts remaining | Appended to session immediately |
"followUp" |
Delivered after agent finishes all work | Appended to session immediately |
"nextTurn" |
Queued as context for next user message | Queued as context for next user message |
The "nextTurn" mode is useful for notifications that shouldn't wake the agent but should be seen on the next turn. The message becomes an "aside" - included alongside the next user prompt as context, rather than appearing as a standalone entry or triggering immediate response.
// Example: Notify agent about tool changes without interrupting
pi.sendMessage(
{ customType: "notify", content: "Tool configuration was updated", display: true },
{ deliverAs: "nextTurn" }
);
// On next user message, agent sees this as context
triggerTurn option:
- If
triggerTurn: trueand the agent is idle, a new agent loop starts immediately - Ignored when streaming (use
deliverAsto control timing instead) - Ignored when
deliverAs: "nextTurn"(the message waits for user input)
LLM context:
CustomMessageEntryis converted to a user message when building context for the LLM- Only
contentis sent to the LLM;detailsis 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).
// 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:
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.
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.
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:
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 for component details.
pi.exec(command, args, options?)
Execute a shell command:
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:
const toolNames = pi.getActiveTools();
// ["read", "bash", "edit", "write"]
pi.getAllTools()
Get all configured tools (built-in via --tools or default, plus custom tools):
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.
// 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().
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.
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.
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.
pi.events
Shared event bus for communication between hooks and custom tools. Tools can emit events, hooks can listen and wake the agent.
// Listen for events and wake agent when received
pi.events.on("task:complete", (data) => {
pi.sendMessage(
{ customType: "task-notify", content: `Task done: ${data}`, display: true },
{ triggerTurn: true } // Required to wake the agent
);
});
// Unsubscribe when needed
const unsubscribe = pi.events.on("my:channel", handler);
unsubscribe();
Event handlers persist across session switches (registered once at hook load time). Channel names are arbitrary strings; use namespaced names like "toolname:event" to avoid collisions. Handler errors (sync and async) are caught and logged.
Examples
Permission Gate
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
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
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const checkpoints = new Map<string, string>();
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 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_callerrors block the tool (fail-safe)- Errors display in UI with hook path and message
- If a hook hangs, use Ctrl+C to abort
Debugging
- Open VS Code in hooks directory
- Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal")
- Set breakpoints
- Run
pi --hook ./my-hook.ts