co-mono/packages/coding-agent/docs/hooks.md
Mario Zechner 7c553acd1e Add hooks system with pi.send() for external message injection
- Hook discovery from ~/.pi/agent/hooks/, .pi/hooks/, --hook flag
- Events: session_start, session_switch, agent_start/end, turn_start/end, tool_call, tool_result, branch
- tool_call can block execution, tool_result can modify results
- pi.send(text, attachments?) to inject messages from external sources
- UI primitives: ctx.ui.select/confirm/input/notify
- Context: ctx.exec(), ctx.cwd, ctx.sessionFile, ctx.hasUI
- Docs shipped with npm package and binary builds
- System prompt references docs folder
2025-12-10 00:50:30 +01:00

16 KiB

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.

Hook Locations

Hooks are automatically discovered from two locations:

  1. Global hooks: ~/.pi/agent/hooks/*.ts
  2. Project hooks: <cwd>/.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).

Additional Configuration

You can also add explicit hook paths in ~/.pi/agent/settings.json:

{
  "hooks": [
    "/path/to/custom/hook.ts"
  ],
  "hookTimeout": 30000
}
  • hooks: Additional hook file paths (supports ~ expansion)
  • hookTimeout: Timeout in milliseconds for non-interactive hook operations (default: 30000)

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.

import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  pi.on("session_start", async (event, ctx) => {
    ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info");
  });
}

Setup

Create a hooks directory and initialize it:

# Global hooks
mkdir -p ~/.pi/agent/hooks
cd ~/.pi/agent/hooks
npm init -y
npm install @mariozechner/pi-coding-agent

# Or project-local hooks
mkdir -p .pi/hooks
cd .pi/hooks
npm init -y
npm install @mariozechner/pi-coding-agent

Hooks are loaded using jiti, so TypeScript works without compilation.

Events

Lifecycle

pi starts
  │
  ├─► session_start
  │
  ▼
user sends prompt ─────────────────────────────────────────┐
  │                                                        │
  ├─► agent_start                                          │
  │                                                        │
  │   ┌─── turn (repeats while LLM calls tools) ───┐       │
  │   │                                            │       │
  │   ├─► turn_start                               │       │
  │   │                                            │       │
  │   │   LLM responds, may call tools:            │       │
  │   │     ├─► tool_call (can block)              │       │
  │   │     │   tool executes                      │       │
  │   │     └─► tool_result (can modify)           │       │
  │   │                                            │       │
  │   └─► turn_end                                 │       │
  │                                                        │
  └─► agent_end                                            │
                                                           │
user sends another prompt ◄────────────────────────────────┘

user branches or switches session
  │
  └─► session_switch

A turn is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.

session_start

Fired once when pi starts.

pi.on("session_start", async (event, ctx) => {
  // ctx.sessionFile: string | null
  // ctx.hasUI: boolean
});

session_switch

Fired when session changes (/branch or session switch).

pi.on("session_switch", async (event, ctx) => {
  // event.newSessionFile: string
  // event.previousSessionFile: string
  // event.reason: "branch" | "switch"
});

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: AppMessage[] - new messages from this prompt
});

turn_start / turn_end

Fired for each turn within an agent loop.

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

pi.on("turn_end", async (event, ctx) => {
  // event.turnIndex: number
  // event.message: AppMessage - assistant's response
  // event.toolResults: AppMessage[]
});

tool_call

Fired before tool executes. Can block.

pi.on("tool_call", async (event, ctx) => {
  // event.toolName: "bash" | "read" | "write" | "edit" | "ls" | "find" | "grep"
  // event.toolCallId: string
  // event.input: Record<string, unknown>
  return { block: true, reason: "..." }; // or undefined to allow
});

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.result: string
  // event.isError: boolean
  return { result: "modified" }; // or undefined to keep original
});

branch

Fired when user branches via /branch.

pi.on("branch", async (event, ctx) => {
  // event.targetTurnIndex: number
  // event.entries: SessionEntry[]
  return { skipConversationRestore: true }; // or undefined
});

Context API

Every event handler receives a context object with these methods:

ctx.ui.select(title, options)

Show a selector dialog. Returns the selected option or null if cancelled.

const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
if (choice === "Option A") {
  // ...
}

ctx.ui.confirm(title, message)

Show a confirmation dialog. Returns true if confirmed, false otherwise.

const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
if (confirmed) {
  // ...
}

ctx.ui.input(title, placeholder?)

Show a text input dialog. Returns the input string or null if cancelled.

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

ctx.ui.notify("Operation complete", "info");
ctx.ui.notify("Something went wrong", "error");

ctx.exec(command, args)

Execute a command and get the result.

const result = await ctx.exec("git", ["status"]);
// result.stdout: string
// result.stderr: string
// result.code: number

ctx.cwd

The current working directory.

console.log(`Working in: ${ctx.cwd}`);

ctx.sessionFile

Path to the session file, or null if running with --no-session.

if (ctx.sessionFile) {
  console.log(`Session: ${ctx.sessionFile}`);
}

ctx.hasUI

Whether interactive UI is available. false in print and RPC modes.

if (ctx.hasUI) {
  const choice = await ctx.ui.select("Pick:", ["A", "B"]);
} else {
  // Fall back to default behavior
}

Sending Messages

Hooks can inject messages into the agent session using pi.send(). This is useful for:

  • Waking up the agent when an external event occurs (file change, CI result, etc.)
  • Async debugging (inject debug output from other processes)
  • Triggering agent actions from external systems
pi.send(text: string, attachments?: Attachment[]): void

If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.

Example: File Watcher

import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  pi.on("session_start", async (event, ctx) => {
    // Watch a trigger file
    const triggerFile = "/tmp/agent-trigger.txt";
    
    fs.watch(triggerFile, () => {
      try {
        const content = fs.readFileSync(triggerFile, "utf-8").trim();
        if (content) {
          pi.send(`External trigger: ${content}`);
          fs.writeFileSync(triggerFile, ""); // Clear after reading
        }
      } catch {
        // File might not exist yet
      }
    });
    
    ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
  });
}

To trigger: echo "Run the tests" > /tmp/agent-trigger.txt

Example: HTTP Webhook

import * as http from "node:http";
import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  pi.on("session_start", async (event, ctx) => {
    const server = http.createServer((req, res) => {
      let body = "";
      req.on("data", chunk => body += chunk);
      req.on("end", () => {
        pi.send(body || "Webhook triggered");
        res.writeHead(200);
        res.end("OK");
      });
    });
    
    server.listen(3333, () => {
      ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
    });
  });
}

To trigger: curl -X POST http://localhost:3333 -d "CI build failed"

Note: pi.send() is not supported in print mode (single-shot execution).

Examples

Shitty Permission Gate

import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  const dangerousPatterns = [
    /\brm\s+(-rf?|--recursive)/i,
    /\bsudo\b/i,
    /\b(chmod|chown)\b.*777/i,
  ];

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return undefined;

    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" };
      }
    }

    return undefined;
  });
}

Git Checkpointing

Stash code state at each turn so /branch can restore it.

import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  const checkpoints = new Map<number, string>();

  pi.on("turn_start", async (event, ctx) => {
    // Create a git stash entry before LLM makes changes
    const { stdout } = await ctx.exec("git", ["stash", "create"]);
    const ref = stdout.trim();
    if (ref) {
      checkpoints.set(event.turnIndex, ref);
    }
  });

  pi.on("branch", async (event, ctx) => {
    const ref = checkpoints.get(event.targetTurnIndex);
    if (!ref) return undefined;

    const choice = await ctx.ui.select("Restore code state?", [
      "Yes, restore code to that point",
      "No, keep current code",
    ]);

    if (choice?.startsWith("Yes")) {
      await ctx.exec("git", ["stash", "apply", ref]);
      ctx.ui.notify("Code restored to checkpoint", "info");
    }

    return undefined;
  });

  pi.on("agent_end", async () => {
    checkpoints.clear();
  });
}

Block Writes to Certain Paths

import type { HookAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  const protectedPaths = [".env", ".git/", "node_modules/"];

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "write" && event.toolName !== "edit") {
      return undefined;
    }

    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` };
    }

    return undefined;
  });
}

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 errors or times out, the tool is blocked (fail-safe)
  • 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<eventType, handlers[]> }

Tool Wrapping

Tools are wrapped with hook callbacks 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:

class HookRunner {
  constructor(hooks: LoadedHook[], cwd: string, timeout?: number)
  
  setUIContext(ctx: HookUIContext, hasUI: boolean): void
  setSessionFile(path: string | null): void
  onError(listener): () => void
  hasHandlers(eventType: string): boolean
  emit(event: HookEvent): Promise<Result>
  emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
}

Key behaviors:

  • emit() has a timeout (default 30s) for safety
  • emitToolCall() has no timeout (user prompts can take any amount of time)
  • Errors in emit() are caught and reported via onError()
  • Errors in emitToolCall() propagate (causing tool to be blocked)

Event Flow

Mode initialization:
  -> hookRunner.setUIContext(ctx, hasUI)
  -> hookRunner.setSessionFile(path)
  -> hookRunner.emit({ type: "session_start" })

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

Branch or session switch:
  -> AgentSession.branch() or AgentSession.switchSession()
     -> hookRunner.emit({ type: "session_switch", ... })

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