co-mono/packages/coding-agent/docs/hooks.md
Mario Zechner 679343de55 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
2025-12-31 12:33:13 +01:00

663 lines
17 KiB
Markdown

# Hooks
Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more.
**Example use cases:**
- 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.
## Quick Start
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_start", async (_event, ctx) => {
ctx.ui.notify("Hook loaded!", "info");
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
}
```
Test with `--hook` flag:
```bash
pi --hook ./my-hook.ts
```
## Hook Locations
Hooks are auto-discovered from:
| Location | Scope |
|----------|-------|
| `~/.pi/agent/hooks/*.ts` | Global (all projects) |
| `.pi/hooks/*.ts` | Project-local |
Additional paths via `settings.json`:
```json
{
"hooks": ["/path/to/hook.ts"],
"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 Overview
```
pi starts
└─► session_start
user sends prompt ─────────────────────────────────────────┐
│ │
├─► before_agent_start (can inject message) │
├─► agent_start │
│ │
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
│ │ │ │
│ ├─► turn_start │ │
│ ├─► context (can modify messages) │ │
│ │ │ │
│ │ LLM responds, may call tools: │ │
│ │ ├─► tool_call (can block) │ │
│ │ │ tool executes │ │
│ │ └─► tool_result (can modify) │ │
│ │ │ │
│ └─► turn_end │ │
│ │
└─► agent_end │
user sends another prompt ◄────────────────────────────────┘
/new (new session)
├─► session_before_new (can cancel)
└─► session_new
/resume (switch session)
├─► session_before_switch (can cancel)
└─► session_switch
/branch
├─► session_before_branch (can cancel)
└─► session_branch
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
└─► session_compact
/tree navigation
├─► session_before_tree (can cancel or customize)
└─► session_tree
exit (Ctrl+C, Ctrl+D)
└─► session_shutdown
```
### Session Events
#### session_start
Fired on initial session load.
```typescript
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
```
#### session_before_switch / session_switch
Fired when switching sessions via `/resume`.
```typescript
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
});
```
#### session_before_new / session_new
Fired when starting a new session via `/new`.
```typescript
pi.on("session_before_new", async (_event, ctx) => {
const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
if (!ok) return { cancel: true };
});
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_end", async (event, ctx) => {
// event.messages - messages from this prompt
});
```
#### turn_start / turn_end
Fired for each turn (one LLM response + tool calls).
```typescript
pi.on("turn_start", async (event, ctx) => {
// event.turnIndex, event.timestamp
});
pi.on("turn_end", async (event, ctx) => {
// event.turnIndex
// event.message - assistant's response
// event.toolResults - tool results from this turn
});
```
#### context
Fired before each LLM call. Modify messages non-destructively (session unchanged).
```typescript
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
// Filter or transform messages
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});
```
### Tool Events
#### tool_call
Fired before tool executes. **Can block.** No timeout.
```typescript
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit", etc.
// event.toolCallId
// event.input - tool parameters
if (shouldBlock(event)) {
return { block: true, reason: "Not allowed" };
}
});
```
Tool inputs:
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
- `edit`: `{ path, oldText, newText }`
- `ls`: `{ path?, limit? }`
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
#### tool_result
Fired after tool executes. **Can modify result.**
```typescript
pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.toolCallId, event.input
// event.content - array of TextContent | ImageContent
// event.details - tool-specific (see below)
// event.isError
// Modify result:
return { content: [...], details: {...}, isError: false };
});
```
Use type guards for typed details:
```typescript
import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks";
pi.on("tool_result", async (event, ctx) => {
if (isBashToolResult(event)) {
// event.details is BashToolDetails | undefined
if (event.details?.truncation?.truncated) {
// Full output at event.details.fullOutputPath
}
}
});
```
Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
## HookContext
Every handler receives `ctx: HookContext`:
### ctx.ui
UI methods for user interaction:
```typescript
// Select from options
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
// Returns selected string or undefined if cancelled
// Confirm dialog
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
// Returns true or false
// Text input
const name = await ctx.ui.input("Name:", "placeholder");
// Returns string or undefined if cancelled
// Notification
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
// 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
`false` in print mode (`-p`) and RPC mode. Always check before using `ctx.ui`:
```typescript
if (ctx.hasUI) {
const choice = await ctx.ui.select(...);
} else {
// Default behavior
}
```
### ctx.cwd
Current working directory.
### ctx.sessionManager
Read-only access to session state:
```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 `CustomMessageEntry` (participates in LLM context).
```typescript
pi.sendMessage({
customType: "my-hook", // Your hook's identifier
content: "Message text", // string or (TextContent | ImageContent)[]
display: true, // Show in TUI
details: { ... }, // Optional metadata (not sent to LLM)
}, triggerTurn); // If true, triggers LLM response
```
### pi.appendEntry(customType, data?)
Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context).
```typescript
// Save state
pi.appendEntry("my-hook-state", { count: 42 });
// Restore on reload
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === "my-hook-state") {
// Reconstruct from entry.data
}
}
});
```
### pi.registerCommand(name, options)
Register a custom slash command:
```typescript
pi.registerCommand("stats", {
description: "Show session statistics",
handler: async (args, ctx) => {
// args = everything after /stats
const count = ctx.sessionManager.getEntries().length;
ctx.ui.notify(`${count} entries`, "info");
}
});
```
To trigger LLM after command, call `pi.sendMessage(..., true)`.
### pi.registerMessageRenderer(customType, renderer)
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:
```typescript
const result = await pi.exec("git", ["status"], {
signal, // AbortSignal
timeout, // Milliseconds
});
// result.stdout, result.stderr, result.code, result.killed
```
## Examples
### Permission Gate
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = event.input.command as string;
if (dangerous.some(p => p.test(cmd))) {
if (!ctx.hasUI) {
return { block: true, reason: "Dangerous (no UI)" };
}
const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
}
```
### Protected Paths
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const protectedPaths = [".env", ".git/", "node_modules/"];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return;
const path = event.input.path as string;
if (protectedPaths.some(p => path.includes(p))) {
ctx.ui.notify(`Blocked: ${path}`, "warning");
return { block: true, reason: `Protected: ${path}` };
}
});
}
```
### Git Checkpoint
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
const checkpoints = new Map<number, string>();
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());
}
```
### Custom Command
See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence.
## Mode Behavior
| Mode | UI Methods | Notes |
|------|-----------|-------|
| Interactive | Full TUI | Normal operation |
| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this.
## Error Handling
- 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
## Debugging
1. Open VS Code in hooks directory
2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal")
3. Set breakpoints
4. Run `pi --hook ./my-hook.ts`