> 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)              │
  ├─► 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 persistent message.

```typescript
pi.on("before_agent_start", async (event, ctx) => {
  // event.prompt - user's prompt text
  // event.images - attached images (if any)

  return {
    message: {
      customType: "my-hook",
      content: "Additional context for the LLM",
      display: true,  // Show in TUI
    }
  };
});
```

The injected message is persisted as `CustomMessageEntry` and sent to the LLM.

#### 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)
- Use `setWidget()` for simple styled text, `setWidgetComponent()` for custom components
- 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, use `setWidgetComponent()` to render a custom TUI component:

```typescript
ctx.ui.setWidgetComponent("my-widget", (tui, theme) => {
  // Return any Component that implements render(width): string[]
  return new MyCustomComponent(tui, theme);
});

// Clear the widget
ctx.ui.setWidgetComponent("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"]);
```

Only built-in tools can be enabled/disabled. Custom tools are always active. 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<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](../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`
