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

17 KiB

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/ for working implementations.

Quick Start

Create ~/.pi/agent/hooks/my-hook.ts:

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:

pi --hook ./my-hook.ts

Hook Locations

Hooks are auto-discovered from:

Location Scope
~/.pi/agent/hooks/*.ts Global (all projects)
.pi/hooks/*.ts Project-local

Additional paths via settings.json:

{
  "hooks": ["/path/to/hook.ts"],
  "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:

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, 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.

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.

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.

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.

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 for details.

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, signal } = event;
  
  // Cancel:
  return { cancel: true };
  
  // Custom summary:
  return {
    compaction: {
      summary: "...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
    }
  };
});

pi.on("session_compact", async (event, ctx) => {
  // event.compactionEntry - the saved compaction
  // event.fromHook - whether hook provided it
});

session_before_tree / session_tree

Fired on /tree navigation. See compaction.md for details.

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).

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.

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.

pi.on("agent_start", async (_event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages - messages from this prompt
});

turn_start / turn_end

Fired for each turn (one LLM response + tool calls).

pi.on("turn_start", async (event, ctx) => {
  // event.turnIndex, event.timestamp
});

pi.on("turn_end", async (event, ctx) => {
  // event.turnIndex
  // event.message - assistant's response
  // event.toolResults - tool results from this turn
});

context

Fired before each LLM call. Modify messages non-destructively (session unchanged).

pi.on("context", async (event, ctx) => {
  // event.messages - deep copy, safe to modify
  
  // Filter or transform messages
  const filtered = event.messages.filter(m => !shouldPrune(m));
  return { messages: filtered };
});

Tool Events

tool_call

Fired before tool executes. Can block. No timeout.

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.

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:

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:

// 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:

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:

// 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:

// Get API key for a model
const apiKey = await ctx.modelRegistry.getApiKey(model);

// Get available models
const models = ctx.modelRegistry.getAvailableModels();

ctx.model

Current model, or undefined if none selected yet. Use for LLM calls in hooks:

if (ctx.model) {
  const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
  // Use with @mariozechner/pi-ai complete()
}

HookAPI Methods

pi.on(event, handler)

Subscribe to events. See Events for all event types.

pi.sendMessage(message, triggerTurn?)

Inject a message into the session. Creates CustomMessageEntry (participates in LLM context).

pi.sendMessage({
  customType: "my-hook",      // Your hook's identifier
  content: "Message text",    // string or (TextContent | ImageContent)[]
  display: true,              // Show in TUI
  details: { ... },           // Optional metadata (not sent to LLM)
}, triggerTurn);              // If true, triggers LLM response

pi.appendEntry(customType, data?)

Persist hook state. Creates CustomEntry (does NOT participate in LLM context).

// Save state
pi.appendEntry("my-hook-state", { count: 42 });

// Restore on reload
pi.on("session_start", async (_event, ctx) => {
  for (const entry of ctx.sessionManager.getEntries()) {
    if (entry.type === "custom" && entry.customType === "my-hook-state") {
      // Reconstruct from entry.data
    }
  }
});

pi.registerCommand(name, options)

Register a custom slash command:

pi.registerCommand("stats", {
  description: "Show session statistics",
  handler: async (args, ctx) => {
    // args = everything after /stats
    const count = ctx.sessionManager.getEntries().length;
    ctx.ui.notify(`${count} entries`, "info");
  }
});

To trigger LLM after command, call pi.sendMessage(..., true).

pi.registerMessageRenderer(customType, renderer)

Custom TUI rendering for CustomMessageEntry:

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:

const result = await pi.exec("git", ["status"], {
  signal,      // AbortSignal
  timeout,     // Milliseconds
});

// result.stdout, result.stderr, result.code, result.killed

Examples

Permission Gate

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

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

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 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