diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md
new file mode 100644
index 00000000..a1753a4e
--- /dev/null
+++ b/packages/coding-agent/docs/compaction.md
@@ -0,0 +1,329 @@
+# Compaction & Branch Summarization
+
+LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
+
+## Overview
+
+Pi has two summarization mechanisms:
+
+| Mechanism | Trigger | Purpose |
+|-----------|---------|---------|
+| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
+| Branch summarization | `/tree` navigation | Preserve context when switching branches |
+
+Both use the same structured summary format and track file operations cumulatively.
+
+## Compaction
+
+### When It Triggers
+
+Auto-compaction triggers when:
+
+```
+contextTokens > contextWindow - reserveTokens
+```
+
+By default, `reserveTokens` is 16384 tokens. This leaves room for the LLM's response.
+
+You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
+
+### How It Works
+
+1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k) is reached
+2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
+3. **Generate summary**: Call LLM to summarize with structured format
+4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
+5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
+
+```
+Before compaction:
+
+ entry: 0 1 2 3 4 5 6 7 8 9
+ ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
+ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
+ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
+ └────────┬───────┘ └──────────────┬──────────────┘
+ messagesToSummarize kept messages
+ ↑
+ firstKeptEntryId (entry 4)
+
+After compaction (new entry appended):
+
+ entry: 0 1 2 3 4 5 6 7 8 9 10
+ ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
+ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
+ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
+ └──────────┬──────┘ └──────────────────────┬───────────────────┘
+ not sent to LLM sent to LLM
+ ↑
+ starts from firstKeptEntryId
+
+What the LLM sees:
+
+ ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
+ │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
+ └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
+ ↑ ↑ └─────────────────┬────────────────┘
+ prompt from cmp messages from firstKeptEntryId
+```
+
+### Split Turns
+
+A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
+
+When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
+
+```
+Split turn (one huge turn exceeds budget):
+
+ entry: 0 1 2 3 4 5 6 7 8
+ ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
+ │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
+ └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
+ ↑ ↑
+ turnStartIndex = 1 firstKeptEntryId = 7
+ │ │
+ └──── turnPrefixMessages (1-6) ───────┘
+ └── kept (7-8)
+
+ isSplitTurn = true
+ messagesToSummarize = [] (no complete turns before)
+ turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
+```
+
+For split turns, pi generates two summaries and merges them:
+1. **History summary**: Previous context (if any)
+2. **Turn prefix summary**: The early part of the split turn
+
+### Cut Point Rules
+
+Valid cut points are:
+- User messages
+- Assistant messages
+- BashExecution messages
+- Hook messages (custom_message, branch_summary)
+
+Never cut at tool results (they must stay with their tool call).
+
+### CompactionEntry Structure
+
+```typescript
+interface CompactionEntry {
+ type: "compaction";
+ id: string;
+ parentId: string;
+ timestamp: number;
+ summary: string;
+ firstKeptEntryId: string;
+ tokensBefore: number;
+ fromHook?: boolean;
+ details?: CompactionDetails;
+}
+
+interface CompactionDetails {
+ readFiles: string[];
+ modifiedFiles: string[];
+}
+```
+
+## Branch Summarization
+
+### When It Triggers
+
+When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This preserves context so you can return later.
+
+### How It Works
+
+1. **Find common ancestor**: Deepest node shared by old and new positions
+2. **Collect entries**: Walk from old leaf back to common ancestor
+3. **Prepare with budget**: Include messages up to token budget (newest first)
+4. **Generate summary**: Call LLM with structured format
+5. **Append entry**: Save `BranchSummaryEntry` at navigation point
+
+```
+Tree before navigation:
+
+ ┌─ B ─ C ─ D (old leaf, being abandoned)
+ A ───┤
+ └─ E ─ F (target)
+
+Common ancestor: A
+Entries to summarize: B, C, D
+
+After navigation with summary:
+
+ ┌─ B ─ C ─ D ─ [summary of B,C,D]
+ A ───┤
+ └─ E ─ F (new leaf)
+```
+
+### Cumulative File Tracking
+
+Branch summaries track files cumulatively. When generating a new summary, pi extracts file operations from:
+- Tool calls in the messages being summarized
+- Previous branch summary `details` (if any)
+
+This means nested summaries accumulate file tracking across the entire abandoned branch.
+
+### BranchSummaryEntry Structure
+
+```typescript
+interface BranchSummaryEntry {
+ type: "branch_summary";
+ id: string;
+ parentId: string;
+ timestamp: number;
+ summary: string;
+ fromId: string; // Entry we navigated from
+ fromHook?: boolean;
+ details?: BranchSummaryDetails;
+}
+
+interface BranchSummaryDetails {
+ readFiles: string[];
+ modifiedFiles: string[];
+}
+```
+
+## Summary Format
+
+Both compaction and branch summarization use the same structured format:
+
+```markdown
+## Goal
+[What the user is trying to accomplish]
+
+## Constraints & Preferences
+- [Requirements mentioned by user]
+
+## Progress
+### Done
+- [x] [Completed tasks]
+
+### In Progress
+- [ ] [Current work]
+
+### Blocked
+- [Issues, if any]
+
+## Key Decisions
+- **[Decision]**: [Rationale]
+
+## Next Steps
+1. [What should happen next]
+
+## Critical Context
+- [Data needed to continue]
+
+
+path/to/file1.ts
+path/to/file2.ts
+
+
+
+path/to/changed.ts
+
+```
+
+### Message Serialization
+
+Before summarization, messages are serialized to text:
+
+```
+[User]: What they said
+[Assistant thinking]: Internal reasoning
+[Assistant]: Response text
+[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
+[Tool result]: Output from tool
+```
+
+This prevents the model from treating it as a conversation to continue.
+
+## Custom Summarization via Hooks
+
+Hooks can intercept and customize both compaction and branch summarization.
+
+### session_before_compact
+
+Fired before auto-compaction or `/compact`. Can cancel or provide custom summary.
+
+```typescript
+pi.on("session_before_compact", async (event, ctx) => {
+ const { preparation, branchEntries, customInstructions, signal } = event;
+
+ // preparation.messagesToSummarize - messages to summarize
+ // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
+ // preparation.previousSummary - previous compaction summary
+ // preparation.fileOps - extracted file operations
+ // preparation.tokensBefore - context tokens before compaction
+ // preparation.firstKeptEntryId - where kept messages start
+ // preparation.settings - compaction settings
+
+ // branchEntries - all entries on current branch (for custom state)
+ // signal - AbortSignal (pass to LLM calls)
+
+ // Cancel:
+ return { cancel: true };
+
+ // Custom summary:
+ return {
+ compaction: {
+ summary: "Your summary...",
+ firstKeptEntryId: preparation.firstKeptEntryId,
+ tokensBefore: preparation.tokensBefore,
+ details: { /* custom data */ },
+ }
+ };
+});
+```
+
+See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
+
+### session_before_tree
+
+Fired before `/tree` navigation with summarization. Can cancel or provide custom summary.
+
+```typescript
+pi.on("session_before_tree", async (event, ctx) => {
+ const { preparation, signal } = event;
+
+ // preparation.targetId - where we're navigating to
+ // preparation.oldLeafId - current position (being abandoned)
+ // preparation.commonAncestorId - shared ancestor
+ // preparation.entriesToSummarize - entries to summarize
+ // preparation.userWantsSummary - whether user chose to summarize
+
+ // Cancel navigation:
+ return { cancel: true };
+
+ // Custom summary (only if userWantsSummary):
+ return {
+ summary: {
+ summary: "Your summary...",
+ details: { /* custom data */ },
+ }
+ };
+});
+```
+
+## Settings
+
+Configure compaction in `~/.pi/agent/settings.json`:
+
+```json
+{
+ "compaction": {
+ "enabled": true,
+ "reserveTokens": 16384,
+ "keepRecentTokens": 20000
+ }
+}
+```
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `true` | Enable auto-compaction |
+| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
+| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
+
+Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md
index 68274e29..8f65959d 100644
--- a/packages/coding-agent/docs/hooks.md
+++ b/packages/coding-agent/docs/hooks.md
@@ -1,108 +1,110 @@
# Hooks
-Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more.
+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.
**Example use cases:**
-- Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.)
-- Checkpoint code state (git stash at each turn, restore on `/branch`)
-- Protect paths (block writes to `.env`, `node_modules/`, etc.)
-- Modify tool output (filter or transform results before the LLM sees them)
-- Inject messages from external sources (file watchers, webhooks, CI systems)
+- Block dangerous commands (permission gates for `rm -rf`, `sudo`)
+- Checkpoint code state (git stash at each turn, restore on branch)
+- Protect paths (block writes to `.env`, `node_modules/`)
+- Inject messages from external sources (file watchers, webhooks)
+- Custom slash commands and UI components
See [examples/hooks/](../examples/hooks/) for working implementations.
-## Hook Locations
+## Quick Start
-Hooks are automatically discovered from two locations:
-
-1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
-2. **Project hooks**: `/.pi/hooks/*.ts`
-
-All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).
-
-You can also load a specific hook file directly using the `--hook` flag:
-
-```bash
-pi --hook ./my-hook.ts
-```
-
-This is useful for testing hooks without placing them in the standard directories.
-
-### Additional Configuration
-
-You can also add explicit hook paths in `~/.pi/agent/settings.json`:
-
-```json
-{
- "hooks": [
- "/path/to/custom/hook.ts"
- ],
- "hookTimeout": 30000
-}
-```
-
-- `hooks`: Additional hook file paths (supports `~` expansion)
-- `hookTimeout`: Timeout in milliseconds for hook operations (default: 30000). Does not apply to `tool_call` events, which have no timeout since they may prompt the user.
-
-## Available Imports
-
-Hooks can import from these packages (automatically resolved by pi):
-
-| Package | Purpose |
-|---------|---------|
-| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, etc.) |
-| `@mariozechner/pi-coding-agent` | Additional types if needed |
-| `@mariozechner/pi-ai` | AI utilities (`ToolResultMessage`, etc.) |
-| `@mariozechner/pi-tui` | TUI components (for advanced use cases) |
-| `@sinclair/typebox` | Schema definitions |
-
-Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
-
-## Writing a Hook
-
-A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.
+Create `~/.pi/agent/hooks/my-hook.ts`:
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
- pi.on("session", async (event, ctx) => {
- ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
+ 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" };
+ }
});
}
```
-### Setup
-
-Create a hooks directory:
+Test with `--hook` flag:
```bash
-# Global hooks
-mkdir -p ~/.pi/agent/hooks
-
-# Or project-local hooks
-mkdir -p .pi/hooks
+pi --hook ./my-hook.ts
```
-Then create `.ts` files directly in these directories. Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically.
+## 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"],
+ "hookTimeout": 30000
+}
+```
+
+The `hookTimeout` (default 30s) applies to most events. `tool_call` has no timeout since it may prompt the user.
+
+## 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/hooks";
+
+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
+### Lifecycle Overview
```
pi starts
│
- ├─► session (reason: "start")
- │
- ▼
+ └─► 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) │ │
@@ -115,225 +117,232 @@ user sends prompt ────────────────────
│
user sends another prompt ◄────────────────────────────────┘
-user branches (/branch)
- │
- ├─► session (reason: "before_branch", can cancel)
- └─► session (reason: "branch", AFTER branch)
+/new (new session)
+ ├─► session_before_new (can cancel)
+ └─► session_new
-user switches session (/resume)
- │
- ├─► session (reason: "before_switch", can cancel)
- └─► session (reason: "switch", AFTER switch)
+/resume (switch session)
+ ├─► session_before_switch (can cancel)
+ └─► session_switch
-user starts new session (/new)
- │
- ├─► session (reason: "before_new", can cancel)
- └─► session (reason: "new", AFTER new session starts)
+/branch
+ ├─► session_before_branch (can cancel)
+ └─► session_branch
-context compaction (auto or /compact)
- │
- ├─► session (reason: "before_compact", can cancel or provide custom summary)
- └─► session (reason: "compact", AFTER compaction)
+/compact or auto-compaction
+ ├─► session_before_compact (can cancel or customize)
+ └─► session_compact
-user exits (double Ctrl+C or Ctrl+D)
- │
- └─► session (reason: "shutdown")
+/tree navigation
+ ├─► session_before_tree (can cancel or customize)
+ └─► session_tree
+
+exit (Ctrl+C, Ctrl+D)
+ └─► session_shutdown
```
-A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
+### Session Events
-### session
+#### session_start
-Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`.
+Fired on initial session load.
```typescript
-pi.on("session", async (event, ctx) => {
- // Access session file: ctx.sessionManager.getSessionFile() (undefined with --no-session)
- // event.previousSessionFile: string | undefined - previous session file (for switch events)
- // event.reason: "start" | "before_switch" | "switch" | "before_new" | "new" |
- // "before_branch" | "branch" | "before_compact" | "compact" | "shutdown"
- // event.targetTurnIndex: number - only for "before_branch" and "branch"
-
- // Cancel a before_* action:
- if (event.reason === "before_new") {
- return { cancel: true };
- }
-
- // For before_branch only: create branch but skip conversation restore
- // (useful for checkpoint hooks that restore files separately)
- if (event.reason === "before_branch") {
- return { skipConversationRestore: true };
- }
+pi.on("session_start", async (_event, ctx) => {
+ ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
```
-**Reasons:**
-- `start`: Initial session load on startup
-- `before_switch` / `switch`: User switched sessions (`/resume`)
-- `before_new` / `new`: User started a new session (`/new`)
-- `before_branch` / `branch`: User branched the session (`/branch`)
-- `before_compact` / `compact`: Context compaction (auto or `/compact`)
-- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)
+#### session_before_switch / session_switch
-For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.
-
-#### Custom Compaction
-
-The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:
-
-**How default compaction works:**
-
-When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):
-
-```
-Legend:
- hdr = header usr = user message ass = assistant message
- tool = tool result cmp = compaction entry bash = bashExecution
-```
-
-```
-Session entries (before compaction):
-
- index: 0 1 2 3 4 5 6 7 8 9 10
- ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
- │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
- └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
- ↑ └───────┬───────┘ └────────────┬────────────┘
- previousSummary messagesToSummarize kept (firstKeptEntryId = "...")
- ↑
- firstKeptEntryIndex = 5
-
-After compaction (new entry appended):
-
- index: 0 1 2 3 4 5 6 7 8 9 10 11
- ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
- │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
- └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
- └──────────┬───────────┘ └────────────────────────┬─────────────────┘
- not sent to LLM sent to LLM
- ↑
- firstKeptEntryId = "..."
- (stored in new cmp)
-```
-
-The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
-
-```
-What gets sent to the LLM as context:
-
- 5 6 7 8 9 10
- ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
- │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
- └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
- ↑ └─────────────────┬────────────────┘
- from new cmp's messages from
- summary firstKeptEntryIndex onwards
-```
-
-**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
-
-```
-Split turn example (one huge turn that exceeds keepRecentTokens):
-
- index: 0 1 2 3 4 5 6 7 8 9
- ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐
- │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │
- └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘
- ↑ ↑
- turnStartIndex = 1 firstKeptEntryIndex = 7
- │ │ (must be usr/ass/bash, not tool)
- └──────── turnPrefixMessages ───────────┘ (idx 1-6, summarized separately)
- └── kept messages (idx 7-9)
-
- isSplitTurn = true
- messagesToSummarize = [] (no complete turns before this one)
- turnPrefixMessages = [usr idx 1, ass idx 2, tool idx 3, ass idx 4, tool idx 5, tool idx 6]
-
-The default compaction generates TWO summaries that get merged:
-1. History summary (previousSummary + messagesToSummarize)
-2. Turn prefix summary (turnPrefixMessages)
-```
-
-See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
-
-**Event fields:**
-
-| Field | Description |
-|-------|-------------|
-| `preparation` | Compaction preparation with `firstKeptEntryId`, `messagesToSummarize`, `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `tokensBefore`, `settings`. |
-| `branchEntries` | All entries on current branch (root to leaf). Use to find previous compactions or hook state. |
-| `customInstructions` | Optional focus for summary (from `/compact `). |
-| `signal` | AbortSignal for cancellation. Pass to LLM calls and check periodically. |
-
-Access session entries via `ctx.sessionManager.getEntries()`, API keys via `ctx.modelRegistry.getApiKey(model)`, and the current model via `ctx.model`.
-
-Custom compaction hooks should honor the abort signal by passing it to `complete()` calls. This allows users to cancel compaction (e.g., via Ctrl+C during `/compact`).
-
-**Returning custom compaction:**
+Fired when switching sessions via `/resume`.
```typescript
-return {
- compaction: {
- summary: "Your summary...",
- firstKeptEntryId: preparation.firstKeptEntryId,
- tokensBefore: preparation.tokensBefore,
- details: { /* optional hook-specific data */ },
- }
-};
+pi.on("session_before_switch", async (event, ctx) => {
+ // event.targetSessionFile - session we're switching to
+ return { cancel: true }; // Cancel the switch
+});
+
+pi.on("session_switch", async (event, ctx) => {
+ // event.previousSessionFile - session we came from
+});
```
-The `details` field persists hook-specific metadata (e.g., artifact index, version markers) in the compaction entry.
+#### session_before_new / session_new
-See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.
+Fired when starting a new session via `/new`.
-**After compaction (`compact` event):**
-- `event.compactionEntry`: The saved compaction entry
-- `event.tokensBefore`: Token count before compaction
-- `event.fromHook`: Whether the compaction entry was provided by a hook
+```typescript
+pi.on("session_before_new", async (_event, ctx) => {
+ const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
+ if (!ok) return { cancel: true };
+});
-### agent_start / agent_end
+pi.on("session_new", async (_event, ctx) => {
+ // New session started
+});
+```
+
+#### session_before_branch / session_branch
+
+Fired when branching via `/branch`.
+
+```typescript
+pi.on("session_before_branch", async (event, ctx) => {
+ // event.entryIndex - entry index 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. 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, userWantsSummary
+
+ return { cancel: true };
+ // OR (if userWantsSummary):
+ 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_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => {
- // event.messages: AppMessage[] - new messages from this prompt
+ // event.messages - messages from this prompt
});
```
-### turn_start / turn_end
+#### turn_start / turn_end
-Fired for each turn within an agent loop.
+Fired for each turn (one LLM response + tool calls).
```typescript
pi.on("turn_start", async (event, ctx) => {
- // event.turnIndex: number
- // event.timestamp: number
+ // event.turnIndex, event.timestamp
});
pi.on("turn_end", async (event, ctx) => {
- // event.turnIndex: number
- // event.message: AppMessage - assistant's response
- // event.toolResults: ToolResultMessage[] - tool results from this turn
+ // event.turnIndex
+ // event.message - assistant's response
+ // event.toolResults - tool results from this turn
});
```
-### tool_call
+#### context
-Fired before tool executes. **Can block.** No timeout (user prompts can take any time).
+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.** No timeout.
```typescript
pi.on("tool_call", async (event, ctx) => {
- // event.toolName: string (built-in or custom tool name)
- // event.toolCallId: string
- // event.input: Record
- return { block: true, reason: "..." }; // or undefined to allow
+ // event.toolName - "bash", "read", "write", "edit", etc.
+ // event.toolCallId
+ // event.input - tool parameters
+
+ if (shouldBlock(event)) {
+ return { block: true, reason: "Not allowed" };
+ }
});
```
-Built-in tool inputs:
+Tool inputs:
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
@@ -342,584 +351,242 @@ Built-in tool inputs:
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
-Custom tools are also intercepted with their own names and input schemas.
-
-### tool_result
+#### tool_result
Fired after tool executes. **Can modify result.**
```typescript
pi.on("tool_result", async (event, ctx) => {
- // event.toolName: string
- // event.toolCallId: string
- // event.input: Record
- // event.content: (TextContent | ImageContent)[]
- // event.details: tool-specific (see below)
- // event.isError: boolean
-
- // Return modified content/details, or undefined to keep original
- return { content: [...], details: {...} };
-});
-```
-
-The event type is a discriminated union based on `toolName`. Use the provided type guards to narrow `details` to the correct type:
-
-```typescript
-import { isBashToolResult, type HookAPI } from "@mariozechner/pi-coding-agent/hooks";
-
-export default function (pi: HookAPI) {
- pi.on("tool_result", async (event, ctx) => {
- if (isBashToolResult(event)) {
- // event.details is BashToolDetails | undefined
- if (event.details?.truncation?.truncated) {
- // Access full output from temp file
- const fullPath = event.details.fullOutputPath;
- }
- }
- });
-}
-```
-
-Available type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
-
-#### Tool Details Types
-
-Each built-in tool has a typed `details` field. Types are exported from `@mariozechner/pi-coding-agent`:
-
-| Tool | Details Type | Source |
-|------|-------------|--------|
-| `bash` | `BashToolDetails` | `src/core/tools/bash.ts` |
-| `read` | `ReadToolDetails` | `src/core/tools/read.ts` |
-| `edit` | `undefined` | - |
-| `write` | `undefined` | - |
-| `grep` | `GrepToolDetails` | `src/core/tools/grep.ts` |
-| `find` | `FindToolDetails` | `src/core/tools/find.ts` |
-| `ls` | `LsToolDetails` | `src/core/tools/ls.ts` |
-
-Common fields in details:
-- `truncation?: TruncationResult` - present when output was truncated
-- `fullOutputPath?: string` - path to temp file with full output (bash only)
-
-`TruncationResult` contains:
-- `truncated: boolean` - whether truncation occurred
-- `truncatedBy: "lines" | "bytes" | null` - which limit was hit
-- `totalLines`, `totalBytes` - original size
-- `outputLines`, `outputBytes` - truncated size
-
-Custom tools use `CustomToolResultEvent` with `details: unknown`. Create your own type guard to get full type safety:
-
-```typescript
-import {
- isBashToolResult,
- type CustomToolResultEvent,
- type HookAPI,
- type ToolResultEvent,
-} from "@mariozechner/pi-coding-agent/hooks";
-
-interface MyCustomToolDetails {
- someField: string;
-}
-
-// Type guard that narrows both toolName and details
-function isMyCustomToolResult(e: ToolResultEvent): e is CustomToolResultEvent & {
- toolName: "my-custom-tool";
- details: MyCustomToolDetails;
-} {
- return e.toolName === "my-custom-tool";
-}
-
-export default function (pi: HookAPI) {
- pi.on("tool_result", async (event, ctx) => {
- // Built-in tool: use provided type guard
- if (isBashToolResult(event)) {
- if (event.details?.fullOutputPath) {
- console.log(`Full output at: ${event.details.fullOutputPath}`);
- }
- }
-
- // Custom tool: use your own type guard
- if (isMyCustomToolResult(event)) {
- // event.details is now MyCustomToolDetails
- console.log(event.details.someField);
- }
- });
-}
-```
-
-**Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues.
-
-### context
-
-Fired before each LLM call, allowing non-destructive message modification. The original session is not modified.
-
-```typescript
-pi.on("context", async (event, ctx) => {
- // event.messages: AgentMessage[] (deep copy, safe to modify)
+ // event.toolName, event.toolCallId, event.input
+ // event.content - array of TextContent | ImageContent
+ // event.details - tool-specific (see below)
+ // event.isError
- // Return modified messages, or undefined to keep original
- return { messages: modifiedMessages };
+ // Modify result:
+ return { content: [...], details: {...}, isError: false };
});
```
-Use case: Dynamic context pruning without modifying session history.
+Use type guards for typed details:
```typescript
-export default function (pi: HookAPI) {
- pi.on("context", async (event, ctx) => {
- // Find all pruning decisions stored as custom entries
- const entries = ctx.sessionManager.getEntries();
- const pruningRules = entries
- .filter(e => e.type === "custom" && e.customType === "prune-rules")
- .flatMap(e => e.data as PruneRule[]);
-
- // Apply pruning to messages (e.g., truncate old tool results)
- const prunedMessages = applyPruning(event.messages, pruningRules);
- return { messages: prunedMessages };
- });
-}
-```
+import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks";
-### before_agent_start
-
-Fired once when user submits a prompt, before `agent_start`. Allows injecting a message that gets persisted.
-
-```typescript
-pi.on("before_agent_start", async (event, ctx) => {
- // event.userMessage: the user's message
-
- // Return a message to inject, or undefined to skip
- return {
- message: {
- customType: "context-injection",
- content: "Additional context...",
- display: true, // Show in TUI
+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
}
- };
+ }
});
```
-The injected message is:
-- Persisted to session as a `CustomMessageEntry`
-- Sent to the LLM as a user message
-- Visible in TUI (if `display: true`)
+Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
-## Context API
+## HookContext
-Every event handler receives a context object with these methods:
+Every handler receives `ctx: HookContext`:
-### ctx.ui.select(title, options)
+### ctx.ui
-Show a selector dialog. Returns the selected option or `null` if cancelled.
+UI methods for user interaction:
```typescript
-const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
-if (choice === "Option A") {
- // ...
-}
-```
+// Select from options
+const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
+// Returns selected string or undefined if cancelled
-### ctx.ui.confirm(title, message)
+// Confirm dialog
+const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
+// Returns true or false
-Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise.
+// Text input
+const name = await ctx.ui.input("Name:", "placeholder");
+// Returns string or undefined if cancelled
-```typescript
-const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
-if (confirmed) {
- // ...
-}
-```
+// Notification
+ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
-### ctx.ui.input(title, placeholder?)
-
-Show a text input dialog. Returns the input string or `null` if cancelled.
-
-```typescript
-const name = await ctx.ui.input("Enter name:", "default value");
-```
-
-### ctx.ui.notify(message, type?)
-
-Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.
-
-```typescript
-ctx.ui.notify("Operation complete", "info");
-ctx.ui.notify("Something went wrong", "error");
-```
-
-### ctx.ui.custom(component, done)
-
-Show a custom TUI component with keyboard focus. Call `done()` when finished.
-
-```typescript
-import { Container, Text } from "@mariozechner/pi-tui";
-
-const myComponent = new Container(0, 0, [
- new Text("Custom UI - press ESC to close", 0, 0),
-]);
-
-ctx.ui.custom(myComponent, () => {
- // Cleanup when component is dismissed
-});
-```
-
-See `examples/hooks/snake.ts` for a complete example with keyboard handling.
-
-### ctx.sessionManager
-
-Access to the session manager for reading session state.
-
-```typescript
-const entries = ctx.sessionManager.getEntries();
-const path = ctx.sessionManager.getPath();
-const tree = ctx.sessionManager.getTree();
-const label = ctx.sessionManager.getLabel(entryId);
-```
-
-### ctx.modelRegistry
-
-Access to model registry for model discovery and API keys.
-
-```typescript
-const apiKey = ctx.modelRegistry.getApiKey(model);
-const models = ctx.modelRegistry.getAvailableModels();
-```
-
-### ctx.cwd
-
-The current working directory.
-
-```typescript
-console.log(`Working in: ${ctx.cwd}`);
-```
-
-### ctx.model
-
-The current model, or `undefined` if no model is selected yet.
-
-```typescript
-if (ctx.model) {
- const apiKey = ctx.modelRegistry.getApiKey(ctx.model);
- // Use for LLM calls
-}
-```
-
-### ctx.sessionManager.getSessionFile()
-
-Path to the current session file, or `undefined` when running with `--no-session` (ephemeral mode).
-
-```typescript
-const sessionFile = ctx.sessionManager.getSessionFile();
-if (sessionFile) {
- console.log(`Session: ${sessionFile}`);
-}
+// Custom component with keyboard focus
+const handle = ctx.ui.custom(myComponent);
+// Returns { close: () => void, requestRender: () => void }
+// Component can implement handleInput(data: string) for keyboard
+// Call handle.close() when done
```
### ctx.hasUI
-Whether interactive UI is available. `false` in print and RPC modes.
+`false` in print mode (`-p`) and RPC mode. Always check before using `ctx.ui`:
```typescript
if (ctx.hasUI) {
- const choice = await ctx.ui.select("Pick:", ["A", "B"]);
+ const choice = await ctx.ui.select(...);
} else {
- // Fall back to default behavior
+ // Default behavior
}
```
-## Hook API Methods
+### ctx.cwd
-The `pi` object provides methods for interacting with the agent:
+Current working directory.
+
+### ctx.sessionManager
+
+Read-only access to session state:
+
+```typescript
+// Get all entries (excludes header)
+const entries = ctx.sessionManager.getEntries();
+
+// Get current branch (root to leaf)
+const branch = ctx.sessionManager.getBranch();
+
+// Get specific entry by ID
+const entry = ctx.sessionManager.getEntry(id);
+
+// Get session file (undefined with --no-session)
+const file = ctx.sessionManager.getSessionFile();
+
+// Get tree structure
+const tree = ctx.sessionManager.getTree();
+
+// Get entry label
+const label = ctx.sessionManager.getLabel(entryId);
+```
+
+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()
+}
+```
+
+## HookAPI Methods
+
+### pi.on(event, handler)
+
+Subscribe to events. See [Events](#events) for all event types.
### pi.sendMessage(message, triggerTurn?)
-Inject a message into the session. Creates a `CustomMessageEntry` (not a user message).
-
-```typescript
-pi.sendMessage(message: HookMessage, triggerTurn?: boolean): void
-
-// HookMessage structure:
-interface HookMessage {
- customType: string; // Your hook's identifier
- content: string | (TextContent | ImageContent)[];
- display: boolean; // true = show in TUI, false = hidden
- details?: unknown; // Hook-specific metadata (not sent to LLM)
-}
-```
-
-- If `triggerTurn` is true (default), starts an agent turn after injecting
-- If streaming, message is queued until current turn ends
-- Messages are persisted to session and sent to LLM as user messages
+Inject a message into the session. Creates `CustomMessageEntry` (participates in LLM context).
```typescript
pi.sendMessage({
- customType: "my-hook",
- content: "External trigger: build failed",
- display: true,
-}, true); // Trigger agent response
+ 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); // If true, triggers LLM response
```
### pi.appendEntry(customType, data?)
-Persist hook state to session. Does NOT participate in LLM context.
+Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context).
```typescript
-pi.appendEntry(customType: string, data?: unknown): void
-```
+// Save state
+pi.appendEntry("my-hook-state", { count: 42 });
-Use for storing state that survives session reload. Scan entries on reload to reconstruct state:
-
-```typescript
-pi.on("session", async (event, ctx) => {
- if (event.reason === "start" || event.reason === "switch") {
- const entries = ctx.sessionManager.getEntries();
- for (const entry of entries) {
- if (entry.type === "custom" && entry.customType === "my-hook") {
- // Reconstruct state from entry.data
- }
+// 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
}
}
});
-
-// Later, save state
-pi.appendEntry("my-hook", { count: 42 });
```
### pi.registerCommand(name, options)
-Register a custom slash command.
-
-```typescript
-pi.registerCommand(name: string, options: {
- description?: string;
- handler: (args: string, ctx: HookContext) => Promise;
-}): void
-```
-
-The handler receives:
-- `args`: Everything after `/commandname` (e.g., `/stats foo` → `"foo"`)
-- `ctx.ui`: UI methods (select, confirm, input, notify, custom)
-- `ctx.hasUI`: Whether interactive UI is available
-- `ctx.cwd`: Current working directory
-- `ctx.model`: Current model (may be undefined)
-- `ctx.sessionManager`: Session access
-- `ctx.modelRegistry`: Model access
+Register a custom slash command:
```typescript
pi.registerCommand("stats", {
description: "Show session statistics",
handler: async (args, ctx) => {
- const entries = ctx.sessionManager.getEntries();
- const messages = entries.filter(e => e.type === "message").length;
- ctx.ui.notify(`${messages} messages in session`, "info");
+ // args = everything after /stats
+ const count = ctx.sessionManager.getEntries().length;
+ ctx.ui.notify(`${count} entries`, "info");
}
});
```
-To prompt the LLM after a command, use `pi.sendMessage()` with `triggerTurn: true`.
+To trigger LLM after command, call `pi.sendMessage(..., true)`.
### pi.registerMessageRenderer(customType, renderer)
-Register a custom TUI renderer for `CustomMessageEntry` messages.
-
-```typescript
-pi.registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void
-
-type HookMessageRenderer = (
- message: HookMessage,
- options: { expanded: boolean; width: number },
- theme: Theme
-) => Component | null;
-```
-
-Return a TUI Component for the inner content. Pi wraps it in a styled box.
+Custom TUI rendering for `CustomMessageEntry`:
```typescript
import { Text } from "@mariozechner/pi-tui";
pi.registerMessageRenderer("my-hook", (message, options, theme) => {
+ // message.content, message.details
+ // options.expanded (user pressed Ctrl+O)
return new Text(theme.fg("accent", `[MY-HOOK] ${message.content}`), 0, 0);
});
```
### pi.exec(command, args, options?)
-Execute a shell command.
+Execute a shell command:
```typescript
-const result = await pi.exec(command: string, args: string[], options?: {
- signal?: AbortSignal;
- timeout?: number;
-}): Promise;
+const result = await pi.exec("git", ["status"], {
+ signal, // AbortSignal
+ timeout, // Milliseconds
+});
-interface ExecResult {
- stdout: string;
- stderr: string;
- code: number;
- killed?: boolean; // True if killed by signal/timeout
-}
+// result.stdout, result.stderr, result.code, result.killed
```
-```typescript
-const result = await pi.exec("git", ["status"]);
-if (result.code === 0) {
- console.log(result.stdout);
-}
-
-// With timeout
-const result = await pi.exec("slow-command", [], { timeout: 5000 });
-if (result.killed) {
- console.log("Command timed out");
-}
-```
-
-## Sending Messages (Examples)
-
-### Example: File Watcher
-
-```typescript
-import * as fs from "node:fs";
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
-
-export default function (pi: HookAPI) {
- pi.on("session", async (event, ctx) => {
- if (event.reason !== "start") return;
-
- const triggerFile = "/tmp/agent-trigger.txt";
-
- fs.watch(triggerFile, () => {
- try {
- const content = fs.readFileSync(triggerFile, "utf-8").trim();
- if (content) {
- pi.sendMessage({
- customType: "file-trigger",
- content: `External trigger: ${content}`,
- display: true,
- }, true);
- fs.writeFileSync(triggerFile, "");
- }
- } catch {
- // File might not exist yet
- }
- });
-
- ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
- });
-}
-```
-
-### Example: HTTP Webhook
-
-```typescript
-import * as http from "node:http";
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
-
-export default function (pi: HookAPI) {
- pi.on("session", async (event, ctx) => {
- if (event.reason !== "start") return;
-
- const server = http.createServer((req, res) => {
- let body = "";
- req.on("data", chunk => body += chunk);
- req.on("end", () => {
- pi.sendMessage({
- customType: "webhook",
- content: body || "Webhook triggered",
- display: true,
- }, true);
- res.writeHead(200);
- res.end("OK");
- });
- });
-
- server.listen(3333, () => {
- ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
- });
- });
-}
-```
-
-**Note:** `pi.sendMessage()` is not supported in print mode (single-shot execution).
-
## Examples
-### Shitty Permission Gate
+### Permission Gate
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
- const dangerousPatterns = [
- /\brm\s+(-rf?|--recursive)/i,
- /\bsudo\b/i,
- /\b(chmod|chown)\b.*777/i,
- ];
+ const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
pi.on("tool_call", async (event, ctx) => {
- if (event.toolName !== "bash") return undefined;
+ if (event.toolName !== "bash") return;
- const command = event.input.command as string;
- const isDangerous = dangerousPatterns.some((p) => p.test(command));
-
- if (isDangerous) {
- const choice = await ctx.ui.select(
- `⚠️ Dangerous command:\n\n ${command}\n\nAllow?`,
- ["Yes", "No"]
- );
-
- if (choice !== "Yes") {
- return { block: true, reason: "Blocked by user" };
+ 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" };
}
-
- return undefined;
});
}
```
-### Git Checkpointing
-
-Stash code state at each turn so `/branch` can restore it.
-
-```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
-
-export default function (pi: HookAPI) {
- const checkpoints = new Map();
-
- pi.on("turn_start", async (event, ctx) => {
- // Create a git stash entry before LLM makes changes
- const { stdout } = await pi.exec("git", ["stash", "create"]);
- const ref = stdout.trim();
- if (ref) {
- checkpoints.set(event.turnIndex, ref);
- }
- });
-
- pi.on("session", async (event, ctx) => {
- // Only handle before_branch events
- if (event.reason !== "before_branch") return;
-
- const ref = checkpoints.get(event.targetTurnIndex);
- if (!ref) return;
-
- const choice = await ctx.ui.select("Restore code state?", [
- "Yes, restore code to that point",
- "No, keep current code",
- ]);
-
- if (choice?.startsWith("Yes")) {
- await pi.exec("git", ["stash", "apply", ref]);
- ctx.ui.notify("Code restored to checkpoint", "info");
- }
- });
-
- pi.on("agent_end", async () => {
- checkpoints.clear();
- });
-}
-```
-
-### Block Writes to Certain Paths
+### Protected Paths
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
@@ -928,196 +595,69 @@ 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 undefined;
- }
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
const path = event.input.path as string;
- const isProtected = protectedPaths.some((p) => path.includes(p));
-
- if (isProtected) {
- ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
- return { block: true, reason: `Path "${path}" is protected` };
+ if (protectedPaths.some(p => path.includes(p))) {
+ ctx.ui.notify(`Blocked: ${path}`, "warning");
+ return { block: true, reason: `Protected: ${path}` };
}
-
- return undefined;
});
}
```
-### Custom Compaction
-
-Use a different model for summarization, or implement your own compaction strategy.
-
-See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) and the [Custom Compaction](#custom-compaction) section above for details.
-
-## Mode Behavior
-
-Hooks behave differently depending on the run mode:
-
-| Mode | UI Methods | Notes |
-|------|-----------|-------|
-| Interactive | Full TUI dialogs | User can interact normally |
-| RPC | JSON protocol | Host application handles UI |
-| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
-
-In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.
-
-## Error Handling
-
-- If a hook throws an error, it's logged and the agent continues
-- If a `tool_call` hook throws an error, the tool is **blocked** (fail-safe)
-- Other events have a timeout (default 30s); timeout errors are logged but don't block
-- Hook errors are displayed in the UI with the hook path and error message
-
-## Debugging
-
-To debug a hook:
-
-1. Open VS Code in your hooks directory
-2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
-3. Set breakpoints in your hook file
-4. Run `pi --hook ./my-hook.ts` in the debug terminal
-
-The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.
-
----
-
-# Internals
-
-## Discovery and Loading
-
-Hooks are discovered and loaded at startup in `main.ts`:
-
-```
-main.ts
- -> discoverAndLoadHooks(configuredPaths, cwd) [loader.ts]
- -> discoverHooksInDir(~/.pi/agent/hooks/) # global hooks
- -> discoverHooksInDir(cwd/.pi/hooks/) # project hooks
- -> merge with configuredPaths (deduplicated)
- -> for each path:
- -> jiti.import(path) # TypeScript support via jiti
- -> hookFactory(hookAPI) # calls pi.on() to register handlers
- -> returns LoadedHook { path, handlers: Map }
-```
-
-## Tool Wrapping
-
-Tools (built-in and custom) are wrapped with hook callbacks after tool discovery/selection, before the agent is created:
-
-```
-main.ts
- -> wrapToolsWithHooks(tools, hookRunner) [tool-wrapper.ts]
- -> returns new tools with wrapped execute() functions
-```
-
-The wrapped `execute()` function:
-
-1. Checks `hookRunner.hasHandlers("tool_call")`
-2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
-3. If result has `block: true`, throws an error
-4. Otherwise, calls the original `tool.execute()`
-5. Checks `hookRunner.hasHandlers("tool_result")`
-6. If yes, calls `hookRunner.emit(event)` (with timeout)
-7. Returns (possibly modified) result
-
-## HookRunner
-
-The `HookRunner` class manages hook execution:
+### Git Checkpoint
```typescript
-class HookRunner {
- constructor(hooks: LoadedHook[], cwd: string, timeout?: number)
+import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
- setUIContext(ctx: HookUIContext, hasUI: boolean): void
- setSessionFile(path: string | null): void
- onError(listener): () => void
- hasHandlers(eventType: string): boolean
- emit(event: HookEvent): Promise
- emitToolCall(event: ToolCallEvent): Promise
+export default function (pi: HookAPI) {
+ const checkpoints = new Map();
+
+ pi.on("turn_start", async (event) => {
+ const { stdout } = await pi.exec("git", ["stash", "create"]);
+ if (stdout.trim()) checkpoints.set(event.turnIndex, stdout.trim());
+ });
+
+ pi.on("session_before_branch", async (event, ctx) => {
+ const ref = checkpoints.get(event.entryIndex);
+ 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());
}
```
-Key behaviors:
-- `emit()` has a timeout (default 30s) for safety
-- `emitToolCall()` has **no timeout** (user prompts can take any time)
-- Errors in `emit()` are caught, logged via `onError()`, and execution continues
-- Errors in `emitToolCall()` propagate, causing the tool to be blocked (fail-safe)
+### Custom Command
-## Event Flow
+See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence.
-```
-Mode initialization:
- -> hookRunner.setUIContext(ctx, hasUI)
- -> hookRunner.setSessionFile(path)
- -> hookRunner.emit({ type: "session", reason: "start", ... })
+## Mode Behavior
-User sends prompt:
- -> AgentSession.prompt()
- -> hookRunner.emit({ type: "agent_start" })
- -> hookRunner.emit({ type: "turn_start", turnIndex })
- -> agent loop:
- -> LLM generates tool calls
- -> For each tool call:
- -> wrappedTool.execute()
- -> hookRunner.emitToolCall({ type: "tool_call", ... })
- -> [if not blocked] originalTool.execute()
- -> hookRunner.emit({ type: "tool_result", ... })
- -> LLM generates response
- -> hookRunner.emit({ type: "turn_end", ... })
- -> [repeat if more tool calls]
- -> hookRunner.emit({ type: "agent_end", messages })
+| 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 |
-Branch:
- -> AgentSession.branch()
- -> hookRunner.emit({ type: "session", reason: "before_branch", ... }) # can cancel
- -> [if not cancelled: branch happens]
- -> hookRunner.emit({ type: "session", reason: "branch", ... })
+In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this.
-Session switch:
- -> AgentSession.switchSession()
- -> hookRunner.emit({ type: "session", reason: "before_switch", ... }) # can cancel
- -> [if not cancelled: switch happens]
- -> hookRunner.emit({ type: "session", reason: "switch", ... })
+## Error Handling
-Clear:
- -> AgentSession.reset()
- -> hookRunner.emit({ type: "session", reason: "before_new", ... }) # can cancel
- -> [if not cancelled: new session starts]
- -> hookRunner.emit({ type: "session", reason: "new", ... })
+- Hook errors are logged, agent continues
+- `tool_call` errors block the tool (fail-safe)
+- Timeout errors (default 30s) are logged but don't block
+- Errors display in UI with hook path and message
-Shutdown (interactive mode):
- -> handleCtrlC() or handleCtrlD()
- -> hookRunner.emit({ type: "session", reason: "shutdown", ... })
- -> process.exit(0)
-```
+## Debugging
-## UI Context by Mode
-
-Each mode provides its own `HookUIContext` implementation:
-
-**Interactive Mode** (`interactive-mode.ts`):
-- `select()` -> `HookSelectorComponent` (TUI list selector)
-- `confirm()` -> `HookSelectorComponent` with Yes/No options
-- `input()` -> `HookInputComponent` (TUI text input)
-- `notify()` -> Adds text to chat container
-
-**RPC Mode** (`rpc-mode.ts`):
-- All methods send JSON requests via stdout
-- Waits for JSON responses via stdin
-- Host application renders UI and sends responses
-
-**Print Mode** (`print-mode.ts`):
-- All methods return null/false immediately
-- `notify()` is a no-op
-
-## File Structure
-
-```
-packages/coding-agent/src/core/hooks/
-├── index.ts # Public exports
-├── types.ts # Event types, HookAPI, contexts
-├── loader.ts # jiti-based hook loading
-├── runner.ts # HookRunner class
-└── tool-wrapper.ts # Tool wrapping for interception
-```
+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`