From 679343de55f464d6a9644aebdb4d2e982b0913cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:32:08 +0100 Subject: [PATCH] Add compaction.md and rewrite hooks.md - New compaction.md covers auto-compaction and branch summarization - Explains cut points, split turns, data model, file tracking - Documents session_before_compact and session_before_tree hooks - Rewritten hooks.md matches actual API (separate event names) - Correct ctx.ui.custom() signature (returns handle, not callback) - Documents all session events including tree events - Adds sessionManager and modelRegistry usage - Updates all examples to use correct API --- packages/coding-agent/docs/compaction.md | 329 ++++++ packages/coding-agent/docs/hooks.md | 1320 +++++++--------------- 2 files changed, 759 insertions(+), 890 deletions(-) create mode 100644 packages/coding-agent/docs/compaction.md 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`