All checks pass. Now let me summarize what I've done and what's next:

Done:
1. Updated system-prompt.ts with better documentation pointers
2. Updated theme.md with:
   - Added `thinkingXhigh` (6th thinking color)
   - Added `bashMode` color section
   - Fixed total count (44 → 46)
   - Fixed Theme class methods (removed dim, added underline)
   - Fixed MarkdownTheme interface
   - Fixed getMarkdownTheme example
   - Fixed theme.dim → theme.italic example
3. Rewrote skills.md with:
   - Clear explanation of what skills are
   - When to use skills vs alternatives (AGENTS.md, slash commands, custom tools)
   - Skill structure (SKILL.md + scripts/ + references/ + assets/)
   - How {baseDir} works
   - Progressive disclosure explanation
   - Real example (brave-search)

Still need to verify:
- hooks.md
- custom-tools.md
- rpc.md

The user might want to continue checking the other docs now.

All checks pass. Here's what I've updated so far:

**system-prompt.ts**: Cleaner documentation pointers

**theme.md**:
- Added `thinkingXhigh` and `bashMode` tokens (44 → 46 total)
- Fixed Theme class methods (`underline()` not `dim()`)
- Updated MarkdownTheme interface to match actual implementation

**skills.md**: Complete rewrite with:
- Clear definition of what skills are
- When to use skills vs AGENTS.md, slash commands, custom tools
- Skill structure (SKILL.md + scripts/ + references/ + assets/)
- How `{baseDir}` works
- Progressive disclosure explanation
- Real example (brave-search)

Still need to verify against source code:
- hooks.md
- custom-tools.md
- rpc.md

Want me to continue with hooks.md next?


open skills.md first so i can review


The user wants to review the skills.md file I just wrote. Let me open it in VS Code.


(no output)


Opened in VS Code.


- Database query function with parameters → make a custom tool nahhh


The user is saying that "Database query function with parameters → make a custom tool" is not a good example. They're right - that's not necessarily a custom tool use case. A custom tool is for when you need something callable by the LLM like the built-in tools (read, write, edit, bash). A database query could just as easily be done via bash with a CLI tool, or via a skill that teaches the agent how to use a database CLI.

Let me think of a better example for "not a good fit for skills" that clearly belongs as a custom tool...

A custom tool makes sense when:
- You need the LLM to be able to call it directly with parameters
- You need return values that go back to the LLM
- You need custom TUI rendering
- You need session state management

Examples might be:
- A tool that needs to prompt the user for confirmation/input during execution
- A tool that needs to maintain state across calls (like the todo example)
- A tool that needs custom rendering in the TUI

Actually, maybe I should just remove that example entirely since it's confusing. Let me replace it with something clearer or just remove it.

You're right, that's a bad example. A database query could just be a skill with CLI instructions. Let me fix that:

edit

**Not a good fit for skills:**
- "Always use TypeScript strict mode" → put in AGENTS.md
- "Review my code" → make a slash command
- Database query function with parameters → make a custom tool

**Not a good fit for skills:**
- "Always use TypeScript strict mode" → put in AGENTS.md
- "Review my code" → make a slash command
- Need user confirmation dialogs or custom TUI rendering → make a custom tool


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/skills.md. Changed 192 characters to 207 characters.

     ...
  23
  24 **Not a good fit for skills:**
  25 - "Always use TypeScript strict mode" → put in AGENTS.md
  26 - "Review my code" → make a slash command
- 27 - Database query function with parameters → make a custom tool
+ 27 - Need user confirmation dialogs or custom TUI rendering → make a custom tool
  28
  29 ## Skill Structure
  30
  31 A skill is a directory containing:
     ...


That's a better example - custom tools are for when you need interactive UI elements (confirmation dialogs, input prompts) or custom rendering in the TUI, which skills can't provide since they're just instructions.


## Skill Structure

A skill is a directory containing:

```
my-skill/
├── SKILL.md              # Required: frontmatter + instructions
├── scripts/              # Optional: helper scripts (bash, python, node)
│   └── process.sh
├── references/           # Optional: detailed docs loaded on-demand
│   └── api-reference.md
└── assets/               # Optional: templates, images, etc.
    └── template.json
```

skills are freeform. the only requirement is a SKILL.md file. so this is just an example on how to structure it.


The user is pointing out that skills are freeform - the only requirement is SKILL.md. The structure I showed (scripts/, references/, assets/) is just one way to organize it, not a requirement. I should make this clearer in the docs.

## Skill Structure

A skill is a directory containing:

```
my-skill/
├── SKILL.md              # Required: frontmatter + instructions
├── scripts/              # Optional: helper scripts (bash, python, node)
│   └── process.sh
├── references/           # Optional: detailed docs loaded on-demand
│   └── api-reference.md
└── assets/               # Optional: templates, images, etc.
    └── template.json
```

## Skill Structure

A skill is a directory with a `SKILL.md` file. Everything else is freeform. Example structure:

```
my-skill/
├── SKILL.md              # Required: frontmatter + instructions
├── scripts/              # Helper scripts (bash, python, node)
│   └── process.sh
├── references/           # Detailed docs loaded on-demand
│   └── api-reference.md
└── assets/               # Templates, images, etc.
    └── template.json
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/skills.md. Changed 409 characters to 439 characters.

     ...
  27 - Need user confirmation dialogs or custom TUI rendering → make a custom tool
  28
  29 ## Skill Structure
  30
- 31 A skill is a directory containing:
+ 31 A skill is a directory with a `SKILL.md` file. Everything else is freeform. Example structure:
  32
  33 ```
  34 my-skill/
  35 ├── SKILL.md              # Required: frontmatter + instructions
- 36 ├── scripts/              # Optional: helper scripts (bash, python, node)
+ 36 ├── scripts/              # Helper scripts (bash, python, node)
  37 │   └── process.sh
- 38 ├── references/           # Optional: detailed docs loaded on-demand
+ 38 ├── references/           # Detailed docs loaded on-demand
  39 │   └── api-reference.md
- 40 └── assets/               # Optional: templates, images, etc.
+ 40 └── assets/               # Templates, images, etc.
  41     └── template.json
  42 ```
  43
  44 ### SKILL.md Format
     ...


Better - now it's clear that only SKILL.md is required and the rest is just an example of how you might structure things.


still there?


The user is checking if I'm still here. Yes, I'm here waiting for more feedback on the skills.md file or further instructions.

Yes! Waiting for more feedback on skills.md or ready to move on to the next doc (hooks.md, custom-tools.md, or rpc.md).


skills look good, do we need to update README.md as well wrt skills?


we should probably reference existing skill repositorys in skills.md as well


Good points. Let me check what the README.md says about skills and see if it needs updating to match the new skills.md. I should also add references to skill repositories like the Anthropic skills repo and the pi-skills repo.

bash

grep -A 60 "### Skills" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md | head -70


### Skills

Skills are instruction files loaded on-demand when tasks match their descriptions. Compatible with Claude Code and Codex CLI skill formats.

**Skill locations:**
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive, colon-separated names)
- Pi project: `.pi/skills/**/SKILL.md` (recursive, colon-separated names)
- Claude Code user: `~/.claude/skills/*/SKILL.md` (one level)
- Claude Code project: `.claude/skills/*/SKILL.md` (one level)
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)

Later locations win on name collisions (Pi skills override Claude/Codex).

Pi skills in subdirectories use colon-separated names: `~/.pi/agent/skills/db/migrate/SKILL.md` → `db:migrate`

**Format:**

```markdown
---
description: Extract text and tables from PDF files
---

# PDF Processing

Use `pdftotext` for plain text extraction.
For tables, use `tabula-py`.

Helper scripts: {baseDir}/scripts/
```

- `description`: Required. Shown in system prompt for agent to decide when to load.
- `name`: Optional. Overrides directory name.
- `{baseDir}`: Placeholder for the skill's directory. Agent substitutes it when following instructions.

**How it works:**

Skills are listed in the system prompt with descriptions:

```
<available_skills>
- pdf-extract: Extract text and tables from PDF files
  File: ~/.pi/agent/skills/pdf-extract/SKILL.md
  Base directory: ~/.pi/agent/skills/pdf-extract
</available_skills>
```

Agent uses `read` tool to load full instructions when needed.

**Disable skills:**

CLI: `pi --no-skills`

Settings (`~/.pi/agent/settings.json`):
```json
{
  "skills": {
    "enabled": false
  }
}
```


The README.md section on skills is decent but could be improved to match the new framing. It's missing:
1. Clear explanation of what skills ARE (capability packages, not just instruction files)
2. When to use skills vs alternatives
3. References to skill repositories

Let me update the README section and also add skill repository references to skills.md.

The README section is okay but could be improved. Let me:
1. Update README.md to match the new framing
2. Add skill repository references to skills.md

edit

### Skills

Skills are instruction files loaded on-demand when tasks match their descriptions. Compatible with Claude Code and Codex CLI skill formats.

**Skill locations:**
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive, colon-separated names)
- Pi project: `.pi/skills/**/SKILL.md` (recursive, colon-separated names)
- Claude Code user: `~/.claude/skills/*/SKILL.md` (one level)
- Claude Code project: `.claude/skills/*/SKILL.md` (one level)
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)

Later locations win on name collisions (Pi skills override Claude/Codex).

Pi skills in subdirectories use colon-separated names: `~/.pi/agent/skills/db/migrate/SKILL.md` → `db:migrate`

**Format:**

```markdown
---
description: Extract text and tables from PDF files
---

# PDF Processing

Use `pdftotext` for plain text extraction.
For tables, use `tabula-py`.

Helper scripts: {baseDir}/scripts/
```

- `description`: Required. Shown in system prompt for agent to decide when to load.
- `name`: Optional. Overrides directory name.
- `{baseDir}`: Placeholder for the skill's directory. Agent substitutes it when following instructions.

**How it works:**

Skills are listed in the system prompt with descriptions:

```
<available_skills>
- pdf-extract: Extract text and tables from PDF files
  File: ~/.pi/agent/skills/pdf-extract/SKILL.md
  Base directory: ~/.pi/agent/skills/pdf-extract
</available_skills>
```

Agent uses `read` tool to load full instructions when needed.

**Disable skills:**

CLI: `pi --no-skills`

Settings (`~/.pi/agent/settings.json`):
```json
{
  "skills": {
    "enabled": false
  }
}
```

See [docs/skills.md](docs/skills.md) for details.

### Skills

Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.

**Skill locations:**
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
- Pi project: `.pi/skills/**/SKILL.md` (recursive)
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)

**Format:**

```markdown
---
description: Web search via Brave Search API. Use for documentation, facts, or web content.
---

# Brave Search

## Setup
\`\`\`bash
cd {baseDir} && npm install
\`\`\`

## Usage
\`\`\`bash
{baseDir}/search.js "query"           # Basic search
{baseDir}/search.js "query" --content # Include page content
\`\`\`
```

- `description`: Required. Determines when the skill is loaded.
- `{baseDir}`: Placeholder for the skill's directory.

**Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings.

See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md. Changed 1676 characters to 1225 characters.

     ...
 487
 488
 489 ### Skills
 490
-491 Skills are instruction files loaded on-demand when tasks match their descriptions. Compatible with Claude Code and Codex CLI skill formats.
+491 Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.
 492
 493 **Skill locations:**
-494 - Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive, colon-separated names)
-495 - Pi project: `.pi/skills/**/SKILL.md` (recursive, colon-separated names)
-496 - Claude Code user: `~/.claude/skills/*/SKILL.md` (one level)
-497 - Claude Code project: `.claude/skills/*/SKILL.md` (one level)
+494 - Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
+495 - Pi project: `.pi/skills/**/SKILL.md` (recursive)
+496 - Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
 498 - Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)
 499
-500 Later locations win on name collisions (Pi skills override Claude/Codex).
-501
-502 Pi skills in subdirectories use colon-separated names: `~/.pi/agent/skills/db/migrate/SKILL.md` → `db:migrate`
-503
 504 **Format:**
 505
 506 ```markdown
 507 ---
-508 description: Extract text and tables from PDF files
+503 description: Web search via Brave Search API. Use for documentation, facts, or web content.
 509 ---
 510
-511 # PDF Processing
+506 # Brave Search
 512
-513 Use `pdftotext` for plain text extraction.
-514 For tables, use `tabula-py`.
+508 ## Setup
+509 \`\`\`bash
+510 cd {baseDir} && npm install
+511 \`\`\`
 515
-516 Helper scripts: {baseDir}/scripts/
+513 ## Usage
+514 \`\`\`bash
+515 {baseDir}/search.js "query"           # Basic search
+516 {baseDir}/search.js "query" --content # Include page content
+517 \`\`\`
 517 ```
 518
-519 - `description`: Required. Shown in system prompt for agent to decide when to load.
-520 - `name`: Optional. Overrides directory name.
-521 - `{baseDir}`: Placeholder for the skill's directory. Agent substitutes it when following instructions.
+520 - `description`: Required. Determines when the skill is loaded.
+521 - `{baseDir}`: Placeholder for the skill's directory.
 522
-523 **How it works:**
+523 **Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings.
 524
-525 Skills are listed in the system prompt with descriptions:
+525 See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories.
 526
-527 ```
-528 <available_skills>
-529 - pdf-extract: Extract text and tables from PDF files
-530   File: ~/.pi/agent/skills/pdf-extract/SKILL.md
-531   Base directory: ~/.pi/agent/skills/pdf-extract
-532 </available_skills>
-533 ```
-534
-535 Agent uses `read` tool to load full instructions when needed.
-536
-537 **Disable skills:**
-538
-539 CLI: `pi --no-skills`
-540
-541 Settings (`~/.pi/agent/settings.json`):
-542 ```json
-543 {
-544   "skills": {
-545     "enabled": false
-546   }
-547 }
-548 ```
-549
-550 See [docs/skills.md](docs/skills.md) for details.
-551
 552 ### Hooks
 553
 554 Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. Use them to:
 555
     ...


Now let me add skill repository references to skills.md.

Now add skill repositories to skills.md:

## Skill Repositories

For inspiration and ready-to-use skills:

- [Anthropic Skills](https://github.com/anthropics/skills) - Official skills for document processing (docx, pdf, pptx, xlsx), web development, and more
- [Pi Skills](https://github.com/nicenemo/pi-skills) - Community skills for web search, browser automation, Google APIs, transcription

## Disabling Skills

CLI:
```bash
pi --no-skills
```

Settings (`~/.pi/agent/settings.json`):
```json
{
  "skills": {
    "enabled": false
  }
}
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/skills.md. Changed 148 characters to 501 characters.

     ...
 159 **Claude Code**: Pi reads skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored.
 160
 161 **Codex CLI**: Pi reads skills from `~/.codex/skills/` recursively. Hidden files/directories and symlinks are skipped.
 162
+163 ## Skill Repositories
+164
+165 For inspiration and ready-to-use skills:
+166
+167 - [Anthropic Skills](https://github.com/anthropics/skills) - Official skills for document processing (docx, pdf, pptx, xlsx), web development, and more
+168 - [Pi Skills](https://github.com/nicenemo/pi-skills) - Community skills for web search, browser automation, Google APIs, transcription
+169
 163 ## Disabling Skills
 164
 165 CLI:
 166 ```bash
     ...


Wait, I need to check the correct URL for pi-skills. Let me verify it's the right repo.

Wait, let me check the correct pi-skills repo URL:

bash

cd /Users/badlogic/workspaces/pi-skills && git remote -v


origin	https://github.com/badlogic/pi-skills.git (fetch)
origin	https://github.com/badlogic/pi-skills.git (push)


Ah, the pi-skills repo is at badlogic/pi-skills, not nicenemo/pi-skills. Let me fix that.

edit

- [Pi Skills](https://github.com/nicenemo/pi-skills) - Community skills for web search, browser automation, Google APIs, transcription

- [Pi Skills](https://github.com/badlogic/pi-skills) - Skills for web search, browser automation, Google APIs, transcription


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/skills.md. Changed 134 characters to 124 characters.

     ...
 164
 165 For inspiration and ready-to-use skills:
 166
 167 - [Anthropic Skills](https://github.com/anthropics/skills) - Official skills for document processing (docx, pdf, pptx, xlsx), web development, and more
-168 - [Pi Skills](https://github.com/nicenemo/pi-skills) - Community skills for web search, browser automation, Google APIs, transcription
+168 - [Pi Skills](https://github.com/badlogic/pi-skills) - Skills for web search, browser automation, Google APIs, transcription
 169
 170 ## Disabling Skills
 171
 172 CLI:
     ...


Good, fixed the URL. Let me run the check to make sure everything compiles.

bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 273 files in 166ms. No fixes applied.

> @mariozechner/pi-agent-core@0.23.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 40 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.23.2 check
> tsgo --noEmit && tsc -p tsconfig.examples.json

> @mariozechner/pi-mom@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 24ms. No fixes applied.

> @mariozechner/pi@0.23.2 check
> biome check --write .

Checked 10 files in 19ms. No fixes applied.

> @mariozechner/pi-proxy@0.23.2 check
> biome check --write .

Checked 3 files in 5ms. No fixes applied.

> @mariozechner/pi-tui@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 26 files in 28ms. No fixes applied.

> @mariozechner/pi-web-ui@0.23.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.11.2 check
> tsc --noEmit


All checks pass. Now let's summarize what's been updated and ask about moving on to the next doc.

Done. Updated:
- **README.md**: Shortened skills section, better framing, cleaner example
- **skills.md**: Added skill repositories section with links to Anthropic and Pi skills repos

Ready to move on to hooks.md, custom-tools.md, or rpc.md?


hooks


Let me check the hooks implementation against the hooks.md documentation to see if there are any discrepancies.

find /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src -path "*/hooks*" -name "*.ts" | head -10


Let me read the types.ts file first to understand the actual hook API and events, then compare against the docs.


/**
 * Hook system types.
 *
 * Hooks are TypeScript modules that can subscribe to agent lifecycle events
 * and interact with the user via UI primitives.
 */

import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { SessionEntry } from "../session-manager.js";

// ============================================================================
// Execution Context
// ============================================================================

/**
 * Result of executing a command via ctx.exec()
 */
export interface ExecResult {
	stdout: string;
	stderr: string;
	code: number;
}

/**
 * UI context for hooks to request interactive UI from the harness.
 * Each mode (interactive, RPC, print) provides its own implementation.
 */
export interface HookUIContext {
	/**
	 * Show a selector and return the user's choice.
	 * @param title - Title to display
	 * @param options - Array of string options
	 * @returns Selected option string, or null if cancelled
	 */
	select(title: string, options: string[]): Promise<string | null>;

	/**
	 * Show a confirmation dialog.
	 * @returns true if confirmed, false if cancelled
	 */
	confirm(title: string, message: string): Promise<boolean>;

	/**
	 * Show a text input dialog.
	 * @returns User input, or null if cancelled
	 */
	input(title: string, placeholder?: string): Promise<string | null>;

	/**
	 * Show a notification to the user.
	 */
	notify(message: string, type?: "info" | "warning" | "error"): void;
}

/**
 * Context passed to hook event handlers.
 */
export interface HookEventContext {
	/** Execute a command and return stdout/stderr/code */
	exec(command: string, args: string[]): Promise<ExecResult>;
	/** UI methods for user interaction */
	ui: HookUIContext;
	/** Whether UI is available (false in print mode) */
	hasUI: boolean;
	/** Current working directory */
	cwd: string;
	/** Path to session file, or null if --no-session */
	sessionFile: string | null;
}

// ============================================================================
// Events
// ============================================================================

/**
 * Event data for session event.
 * Fired on startup and when session changes (switch or clear).
 * Note: branch has its own event that fires BEFORE the branch happens.
 */
export interface SessionEvent {
	type: "session";
	/** All session entries (including pre-compaction history) */
	entries: SessionEntry[];
	/** Current session file path, or null in --no-session mode */
	sessionFile: string | null;
	/** Previous session file path, or null for "start" and "clear" */
	previousSessionFile: string | null;
	/** Reason for the session event */
	reason: "start" | "switch" | "clear";
}

/**
 * Event data for agent_start event.
 * Fired when an agent loop starts (once per user prompt).
 */
export interface AgentStartEvent {
	type: "agent_start";
}

/**
 * Event data for agent_end event.
 */
export interface AgentEndEvent {
	type: "agent_end";
	messages: AppMessage[];
}

/**
 * Event data for turn_start event.
 */
export interface TurnStartEvent {
	type: "turn_start";
	turnIndex: number;
	timestamp: number;
}

/**
 * Event data for turn_end event.
 */
export interface TurnEndEvent {
	type: "turn_end";
	turnIndex: number;
	message: AppMessage;
	toolResults: AppMessage[];
}

/**
 * Event data for tool_call event.
 * Fired before a tool is executed. Hooks can block execution.
 */
export interface ToolCallEvent {
	type: "tool_call";
	/** Tool name (e.g., "bash", "edit", "write") */
	toolName: string;
	/** Tool call ID */
	toolCallId: string;
	/** Tool input parameters */
	input: Record<string, unknown>;
}

/**
 * Event data for tool_result event.
 * Fired after a tool is executed. Hooks can modify the result.
 */
export interface ToolResultEvent {
	type: "tool_result";
	/** Tool name (e.g., "bash", "edit", "write") */
	toolName: string;
	/** Tool call ID */
	toolCallId: string;
	/** Tool input parameters */
	input: Record<string, unknown>;
	/** Tool result content (text) */
	result: string;
	/** Whether the tool execution was an error */
	isError: boolean;
}

/**
 * Event data for branch event.
 */
export interface BranchEvent {
	type: "branch";
	/** Index of the turn to branch from */
	targetTurnIndex: number;
	/** Full session history */
	entries: SessionEntry[];
}

/**
 * Union of all hook event types.
 */
export type HookEvent =
	| SessionEvent
	| AgentStartEvent
	| AgentEndEvent
	| TurnStartEvent
	| TurnEndEvent
	| ToolCallEvent
	| ToolResultEvent
	| BranchEvent;

// ============================================================================
// Event Results
// ============================================================================

/**
 * Return type for tool_call event handlers.
 * Allows hooks to block tool execution.
 */
export interface ToolCallEventResult {
	/** If true, block the tool from executing */
	block?: boolean;
	/** Reason for blocking (returned to LLM as error) */
	reason?: string;
}

/**
 * Return type for tool_result event handlers.
 * Allows hooks to modify tool results.
 */
export interface ToolResultEventResult {
	/** Modified result text (if not set, original result is used) */
	result?: string;
	/** Override isError flag */
	isError?: boolean;
}

/**
 * Return type for branch event handlers.
 * Allows hooks to control branch behavior.
 */
export interface BranchEventResult {
	/** If true, skip restoring the conversation (only restore code) */
	skipConversationRestore?: boolean;
}

// ============================================================================
// Hook API
// ============================================================================

/**
 * Handler function type for each event.
 */
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;

/**
 * HookAPI passed to hook factory functions.
 * Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
 */
export interface HookAPI {
	on(event: "session", handler: HookHandler<SessionEvent>): void;
	on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
	on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
	on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
	on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
	on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
	on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
	on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;

	/**
	 * Send a message to the agent.
	 * If the agent is streaming, the message is queued.
	 * If the agent is idle, a new agent loop is started.
	 */
	send(text: string, attachments?: Attachment[]): void;
}

/**
 * Hook factory function type.
 * Hooks export a default function that receives the HookAPI.
 */
export type HookFactory = (pi: HookAPI) => void;

// ============================================================================
// Errors
// ============================================================================

/**
 * Error emitted when a hook fails.
 */
export interface HookError {
	hookPath: string;
	event: string;
	error: string;
}


Now let me compare this with the hooks.md documentation. Let me read through the docs more carefully:


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

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

```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.sessionFile ?? "ephemeral"}`, "info");
  });
}
```

### Setup

Create a hooks directory:

```bash
# Global hooks
mkdir -p ~/.pi/agent/hooks

# Or project-local hooks
mkdir -p .pi/hooks
```

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.

## Events

### Lifecycle

```
pi starts
  │
  ├─► session (reason: "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 (/branch)
  │
  ├─► branch (BEFORE branch, can control)
  └─► session (reason: "switch", AFTER branch)

user switches session (/session)
  │
  └─► session (reason: "switch")

user clears session (/clear)
  │
  └─► session (reason: "clear")
```

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

### session

Fired on startup and when session changes.

```typescript
pi.on("session", async (event, ctx) => {
  // event.entries: SessionEntry[] - all session entries
  // event.sessionFile: string | null - current session file
  // event.previousSessionFile: string | null - previous session file
  // event.reason: "start" | "switch" | "clear"
});
```

**Reasons:**
- `start`: Initial session load on startup
- `switch`: User switched sessions (`/session`) or branched (`/branch`)
- `clear`: User cleared the session (`/clear`)

### branch

Fired BEFORE a branch happens. Can control branch behavior.

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

Note: After branch completes, a `session` event fires with `reason: "switch"`.

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

### turn_start / turn_end

Fired for each turn within an agent loop.

```typescript
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.**

```typescript
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.**

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

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

```typescript
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.

```typescript
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.

```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.exec(command, args)

Execute a command and get the result.

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

### ctx.cwd

The current working directory.

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

### ctx.sessionFile

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

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

### ctx.hasUI

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

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

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

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

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

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

```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,
  ];

  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.

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

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

```typescript
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", reason: "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:
  -> AgentSession.branch()
     -> hookRunner.emit({ type: "branch", ... })  # BEFORE branch
     -> [branch happens]
     -> hookRunner.emit({ type: "session", reason: "switch", ... })  # AFTER

Session switch:
  -> AgentSession.switchSession()
     -> hookRunner.emit({ type: "session", reason: "switch", ... })

Clear:
  -> AgentSession.reset()
     -> hookRunner.emit({ type: "session", reason: "clear", ... })
```

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


The docs are quite comprehensive. Let me check a few things against the actual implementation:

1. The types.ts shows all the events and their properties - let me verify they match the docs
2. Let me check the loader.ts for how hooks are discovered
3. Let me check the runner.ts for timeout behavior

First, let me compare the events in types.ts vs docs:

From types.ts:
- SessionEvent: entries, sessionFile, previousSessionFile, reason ("start" | "switch" | "clear") ✓
- AgentStartEvent: just type ✓
- AgentEndEvent: messages ✓
- TurnStartEvent: turnIndex, timestamp ✓
- TurnEndEvent: turnIndex, message, toolResults ✓
- ToolCallEvent: toolName, toolCallId, input ✓
- ToolResultEvent: toolName, toolCallId, input, result, isError ✓
- BranchEvent: targetTurnIndex, entries ✓

The events look correct. Let me check the loader for hook discovery paths:


/**
 * Hook loader - loads TypeScript hook modules using jiti.
 */

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Attachment } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookAPI, HookFactory } from "./types.js";

/**
 * Generic handler function type.
 */
type HandlerFn = (...args: unknown[]) => Promise<unknown>;

/**
 * Send handler type for pi.send().
 */
export type SendHandler = (text: string, attachments?: Attachment[]) => void;

/**
 * Registered handlers for a loaded hook.
 */
export interface LoadedHook {
	/** Original path from config */
	path: string;
	/** Resolved absolute path */
	resolvedPath: string;
	/** Map of event type to handler functions */
	handlers: Map<string, HandlerFn[]>;
	/** Set the send handler for this hook's pi.send() */
	setSendHandler: (handler: SendHandler) => void;
}

/**
 * Result of loading hooks.
 */
export interface LoadHooksResult {
	/** Successfully loaded hooks */
	hooks: LoadedHook[];
	/** Errors encountered during loading */
	errors: Array<{ path: string; error: string }>;
}

const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;

function normalizeUnicodeSpaces(str: string): string {
	return str.replace(UNICODE_SPACES, " ");
}

function expandPath(p: string): string {
	const normalized = normalizeUnicodeSpaces(p);
	if (normalized.startsWith("~/")) {
		return path.join(os.homedir(), normalized.slice(2));
	}
	if (normalized.startsWith("~")) {
		return path.join(os.homedir(), normalized.slice(1));
	}
	return normalized;
}

/**
 * Resolve hook path.
 * - Absolute paths used as-is
 * - Paths starting with ~ expanded to home directory
 * - Relative paths resolved from cwd
 */
function resolveHookPath(hookPath: string, cwd: string): string {
	const expanded = expandPath(hookPath);

	if (path.isAbsolute(expanded)) {
		return expanded;
	}

	// Relative paths resolved from cwd
	return path.resolve(cwd, expanded);
}

/**
 * Create a HookAPI instance that collects handlers.
 * Returns the API and a function to set the send handler later.
 */
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
	api: HookAPI;
	setSendHandler: (handler: SendHandler) => void;
} {
	let sendHandler: SendHandler = () => {
		// Default no-op until mode sets the handler
	};

	const api: HookAPI = {
		on(event: string, handler: HandlerFn): void {
			const list = handlers.get(event) ?? [];
			list.push(handler);
			handlers.set(event, list);
		},
		send(text: string, attachments?: Attachment[]): void {
			sendHandler(text, attachments);
		},
	} as HookAPI;

	return {
		api,
		setSendHandler: (handler: SendHandler) => {
			sendHandler = handler;
		},
	};
}

/**
 * Load a single hook module using jiti.
 */
async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> {
	const resolvedPath = resolveHookPath(hookPath, cwd);

	try {
		// Create jiti instance for TypeScript/ESM loading
		const jiti = createJiti(import.meta.url);

		// Import the module
		const module = await jiti.import(resolvedPath, { default: true });
		const factory = module as HookFactory;

		if (typeof factory !== "function") {
			return { hook: null, error: "Hook must export a default function" };
		}

		// Create handlers map and API
		const handlers = new Map<string, HandlerFn[]>();
		const { api, setSendHandler } = createHookAPI(handlers);

		// Call factory to register handlers
		factory(api);

		return {
			hook: { path: hookPath, resolvedPath, handlers, setSendHandler },
			error: null,
		};
	} catch (err) {
		const message = err instanceof Error ? err.message : String(err);
		return { hook: null, error: `Failed to load hook: ${message}` };
	}
}

/**
 * Load all hooks from configuration.
 * @param paths - Array of hook file paths
 * @param cwd - Current working directory for resolving relative paths
 */
export async function loadHooks(paths: string[], cwd: string): Promise<LoadHooksResult> {
	const hooks: LoadedHook[] = [];
	const errors: Array<{ path: string; error: string }> = [];

	for (const hookPath of paths) {
		const { hook, error } = await loadHook(hookPath, cwd);

		if (error) {
			errors.push({ path: hookPath, error });
			continue;
		}

		if (hook) {
			hooks.push(hook);
		}
	}

	return { hooks, errors };
}

/**
 * Discover hook files from a directory.
 * Returns all .ts files in the directory (non-recursive).
 */
function discoverHooksInDir(dir: string): string[] {
	if (!fs.existsSync(dir)) {
		return [];
	}

	try {
		const entries = fs.readdirSync(dir, { withFileTypes: true });
		return entries.filter((e) => e.isFile() && e.name.endsWith(".ts")).map((e) => path.join(dir, e.name));
	} catch {
		return [];
	}
}

/**
 * Discover and load hooks from standard locations:
 * 1. ~/.pi/agent/hooks/*.ts (global)
 * 2. cwd/.pi/hooks/*.ts (project-local)
 *
 * Plus any explicitly configured paths from settings.
 *
 * @param configuredPaths - Explicit paths from settings.json
 * @param cwd - Current working directory
 */
export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise<LoadHooksResult> {
	const allPaths: string[] = [];
	const seen = new Set<string>();

	// Helper to add paths without duplicates
	const addPaths = (paths: string[]) => {
		for (const p of paths) {
			const resolved = path.resolve(p);
			if (!seen.has(resolved)) {
				seen.add(resolved);
				allPaths.push(p);
			}
		}
	};

	// 1. Global hooks: ~/.pi/agent/hooks/
	const globalHooksDir = path.join(getAgentDir(), "hooks");
	addPaths(discoverHooksInDir(globalHooksDir));

	// 2. Project-local hooks: cwd/.pi/hooks/
	const localHooksDir = path.join(cwd, ".pi", "hooks");
	addPaths(discoverHooksInDir(localHooksDir));

	// 3. Explicitly configured paths (can override/add)
	addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd)));

	return loadHooks(allPaths, cwd);
}


The loader looks correct and matches the docs. Let me check the runner for timeout behavior and error handling:


/**
 * Hook runner - executes hooks and manages their lifecycle.
 */

import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type {
	BranchEventResult,
	ExecResult,
	HookError,
	HookEvent,
	HookEventContext,
	HookUIContext,
	ToolCallEvent,
	ToolCallEventResult,
	ToolResultEventResult,
} from "./types.js";

/**
 * Default timeout for hook execution (30 seconds).
 */
const DEFAULT_TIMEOUT = 30000;

/**
 * Listener for hook errors.
 */
export type HookErrorListener = (error: HookError) => void;

/**
 * Execute a command and return stdout/stderr/code.
 */
async function exec(command: string, args: string[], cwd: string): Promise<ExecResult> {
	return new Promise((resolve) => {
		const proc = spawn(command, args, { cwd, shell: false });

		let stdout = "";
		let stderr = "";

		proc.stdout?.on("data", (data) => {
			stdout += data.toString();
		});

		proc.stderr?.on("data", (data) => {
			stderr += data.toString();
		});

		proc.on("close", (code) => {
			resolve({ stdout, stderr, code: code ?? 0 });
		});

		proc.on("error", (_err) => {
			resolve({ stdout, stderr, code: 1 });
		});
	});
}

/**
 * Create a promise that rejects after a timeout.
 */
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
	let timeoutId: NodeJS.Timeout;
	const promise = new Promise<never>((_, reject) => {
		timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
	});
	return {
		promise,
		clear: () => clearTimeout(timeoutId),
	};
}

/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
	select: async () => null,
	confirm: async () => false,
	input: async () => null,
	notify: () => {},
};

/**
 * HookRunner executes hooks and manages event emission.
 */
export class HookRunner {
	private hooks: LoadedHook[];
	private uiContext: HookUIContext;
	private hasUI: boolean;
	private cwd: string;
	private sessionFile: string | null;
	private timeout: number;
	private errorListeners: Set<HookErrorListener> = new Set();

	constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
		this.hooks = hooks;
		this.uiContext = noOpUIContext;
		this.hasUI = false;
		this.cwd = cwd;
		this.sessionFile = null;
		this.timeout = timeout;
	}

	/**
	 * Set the UI context for hooks.
	 * Call this when the mode initializes and UI is available.
	 */
	setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
		this.uiContext = uiContext;
		this.hasUI = hasUI;
	}

	/**
	 * Get the paths of all loaded hooks.
	 */
	getHookPaths(): string[] {
		return this.hooks.map((h) => h.path);
	}

	/**
	 * Set the session file path.
	 */
	setSessionFile(sessionFile: string | null): void {
		this.sessionFile = sessionFile;
	}

	/**
	 * Set the send handler for all hooks' pi.send().
	 * Call this when the mode initializes.
	 */
	setSendHandler(handler: SendHandler): void {
		for (const hook of this.hooks) {
			hook.setSendHandler(handler);
		}
	}

	/**
	 * Subscribe to hook errors.
	 * @returns Unsubscribe function
	 */
	onError(listener: HookErrorListener): () => void {
		this.errorListeners.add(listener);
		return () => this.errorListeners.delete(listener);
	}

	/**
	 * Emit an error to all listeners.
	 */
	private emitError(error: HookError): void {
		for (const listener of this.errorListeners) {
			listener(error);
		}
	}

	/**
	 * Check if any hooks have handlers for the given event type.
	 */
	hasHandlers(eventType: string): boolean {
		for (const hook of this.hooks) {
			const handlers = hook.handlers.get(eventType);
			if (handlers && handlers.length > 0) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Create the event context for handlers.
	 */
	private createContext(): HookEventContext {
		return {
			exec: (command: string, args: string[]) => exec(command, args, this.cwd),
			ui: this.uiContext,
			hasUI: this.hasUI,
			cwd: this.cwd,
			sessionFile: this.sessionFile,
		};
	}

	/**
	 * Emit an event to all hooks.
	 * Returns the result from branch/tool_result events (if any handler returns one).
	 */
	async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
		const ctx = this.createContext();
		let result: BranchEventResult | ToolResultEventResult | undefined;

		for (const hook of this.hooks) {
			const handlers = hook.handlers.get(event.type);
			if (!handlers || handlers.length === 0) continue;

			for (const handler of handlers) {
				try {
					const timeout = createTimeout(this.timeout);
					const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
					timeout.clear();

					// For branch events, capture the result
					if (event.type === "branch" && handlerResult) {
						result = handlerResult as BranchEventResult;
					}

					// For tool_result events, capture the result
					if (event.type === "tool_result" && handlerResult) {
						result = handlerResult as ToolResultEventResult;
					}
				} catch (err) {
					const message = err instanceof Error ? err.message : String(err);
					this.emitError({
						hookPath: hook.path,
						event: event.type,
						error: message,
					});
				}
			}
		}

		return result;
	}

	/**
	 * Emit a tool_call event to all hooks.
	 * No timeout - user prompts can take as long as needed.
	 * Errors are thrown (not swallowed) so caller can block on failure.
	 */
	async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
		const ctx = this.createContext();
		let result: ToolCallEventResult | undefined;

		for (const hook of this.hooks) {
			const handlers = hook.handlers.get("tool_call");
			if (!handlers || handlers.length === 0) continue;

			for (const handler of handlers) {
				// No timeout - let user take their time
				const handlerResult = await handler(event, ctx);

				if (handlerResult) {
					result = handlerResult as ToolCallEventResult;
					// If blocked, stop processing further hooks
					if (result.block) {
						return result;
					}
				}
			}
		}

		return result;
	}
}


The implementation matches the docs pretty well. Let me verify a few more things:

1. The docs say "If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)" - but the code shows NO timeout for tool_call (emitToolCall), and errors propagate (not caught). Let me check if the tool-wrapper handles this correctly.

2. Check the docs mention custom tools can also be intercepted - let me verify if the tool names list is complete.


/**
 * Tool wrapper - wraps tools with hook callbacks for interception.
 */

import type { AgentTool } from "@mariozechner/pi-ai";
import type { HookRunner } from "./runner.js";
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";

/**
 * Wrap a tool with hook callbacks.
 * - Emits tool_call event before execution (can block)
 * - Emits tool_result event after execution (can modify result)
 */
export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRunner): AgentTool<any, T> {
	return {
		...tool,
		execute: async (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal) => {
			// Emit tool_call event - hooks can block execution
			// If hook errors/times out, block by default (fail-safe)
			if (hookRunner.hasHandlers("tool_call")) {
				try {
					const callResult = (await hookRunner.emitToolCall({
						type: "tool_call",
						toolName: tool.name,
						toolCallId,
						input: params,
					})) as ToolCallEventResult | undefined;

					if (callResult?.block) {
						const reason = callResult.reason || "Tool execution was blocked by a hook";
						throw new Error(reason);
					}
				} catch (err) {
					// Hook error or block - throw to mark as error
					if (err instanceof Error) {
						throw err;
					}
					throw new Error(`Hook failed, blocking execution: ${String(err)}`);
				}
			}

			// Execute the actual tool
			const result = await tool.execute(toolCallId, params, signal);

			// Emit tool_result event - hooks can modify the result
			if (hookRunner.hasHandlers("tool_result")) {
				// Extract text from result for hooks
				const resultText = result.content
					.filter((c): c is { type: "text"; text: string } => c.type === "text")
					.map((c) => c.text)
					.join("\n");

				const resultResult = (await hookRunner.emit({
					type: "tool_result",
					toolName: tool.name,
					toolCallId,
					input: params,
					result: resultText,
					isError: false,
				})) as ToolResultEventResult | undefined;

				// Apply modifications if any
				if (resultResult?.result !== undefined) {
					return {
						...result,
						content: [{ type: "text", text: resultResult.result }],
					};
				}
			}

			return result;
		},
	};
}

/**
 * Wrap all tools with hook callbacks.
 */
export function wrapToolsWithHooks<T>(tools: AgentTool<any, T>[], hookRunner: HookRunner): AgentTool<any, T>[] {
	return tools.map((tool) => wrapToolWithHooks(tool, hookRunner));
}


OK, looking at the tool-wrapper:

1. When a tool_call hook fails (throws error), it catches the error and throws a new error, which effectively blocks the tool. So the docs saying "If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)" is partially correct - errors block, but there's NO timeout for tool_call (intentionally, as the comment says "user prompts can take as long as needed").

Actually wait, the docs say:
- "If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)"

But the code in runner.ts shows `emitToolCall` has NO timeout - the comment says "No timeout - user prompts can take as long as needed". So the "times out" part is wrong for tool_call events.

Let me check if custom tools can also be intercepted. The tool-wrapper wraps all tools, so custom tools should also trigger tool_call/tool_result events. The docs list tool names as:
```
event.toolName: "bash" | "read" | "write" | "edit" | "ls" | "find" | "grep"
```

But custom tools would have their own names. The docs should mention that custom tools are also intercepted.

Let me also check if there's anything about tool_result isError handling that's missing.

Issues found:
1. The docs say tool_call hooks can "time out" and block - but there's no timeout for tool_call events (intentional)
2. The docs don't mention that custom tools are also intercepted
3. The docs list only built-in tool names but custom tools have their own names

Let me fix these issues in the docs.

Found some discrepancies:

1. **Timeout claim is wrong for tool_call**: The docs say "If a `tool_call` hook errors or times out, the tool is **blocked**" but there's intentionally NO timeout for `tool_call` events (user prompts can take any time). Only errors block.

2. **Custom tools not mentioned**: The docs only list built-in tool names (`bash`, `read`, `write`, `edit`, `ls`, `find`, `grep`) but custom tools are also intercepted and would have their own names.

Let me fix these:

edit

### tool_call

Fired before tool executes. **Can block.**

```typescript
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_call

Fired before tool executes. **Can block.** No timeout (user prompts can take any time).

```typescript
pi.on("tool_call", async (event, ctx) => {
  // event.toolName: string (built-in or custom tool name)
  // event.toolCallId: string
  // event.input: Record<string, unknown>
  return { block: true, reason: "..." }; // or undefined to allow
});
```

Built-in 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? }`

Custom tools are also intercepted with their own names and input schemas.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 644 characters to 751 characters.

     ...
 170 ```
 171
 172 ### tool_call
 173
-174 Fired before tool executes. **Can block.**
+174 Fired before tool executes. **Can block.** No timeout (user prompts can take any time).
 175
 176 ```typescript
 177 pi.on("tool_call", async (event, ctx) => {
-178   // event.toolName: "bash" | "read" | "write" | "edit" | "ls" | "find" | "grep"
+178   // event.toolName: string (built-in or custom tool name)
 179   // event.toolCallId: string
 180   // event.input: Record<string, unknown>
 181   return { block: true, reason: "..." }; // or undefined to allow
 182 });
 183 ```
 184
-185 Tool inputs:
+185 Built-in tool inputs:
 186 - `bash`: `{ command, timeout? }`
 187 - `read`: `{ path, offset?, limit? }`
 188 - `write`: `{ path, content }`
 189 - `edit`: `{ path, oldText, newText }`
 190 - `ls`: `{ path?, limit? }`
 191 - `find`: `{ pattern, path?, limit? }`
 192 - `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
 193
+194 Custom tools are also intercepted with their own names and input schemas.
+195
 194 ### tool_result
 195
 196 Fired after tool executes. **Can modify result.**
 197
     ...


Now let me fix the error handling section about timeouts:

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

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 239 characters to 322 characters.

     ...
 485
 486 ## Error Handling
 487
 488 - If a hook throws an error, it's logged and the agent continues
-489 - If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)
+489 - If a `tool_call` hook throws an error, the tool is **blocked** (fail-safe)
+490 - Other events have a timeout (default 30s); timeout errors are logged but don't block
 490 - Hook errors are displayed in the UI with the hook path and error message
 491
 492 ## Debugging
 493
     ...


Let me also update the internals section to clarify the timeout behavior for tool_call vs other events:

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)

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)


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 274 characters to 299 characters.

     ...
 559 ```
 560
 561 Key behaviors:
 562 - `emit()` has a timeout (default 30s) for safety
-563 - `emitToolCall()` has **no timeout** (user prompts can take any amount of time)
-564 - Errors in `emit()` are caught and reported via `onError()`
-565 - Errors in `emitToolCall()` propagate (causing tool to be blocked)
+563 - `emitToolCall()` has **no timeout** (user prompts can take any time)
+564 - Errors in `emit()` are caught, logged via `onError()`, and execution continues
+565 - Errors in `emitToolCall()` propagate, causing the tool to be blocked (fail-safe)
 566
 567 ## Event Flow
 568
 569 ```
     ...


Let me run the check to make sure everything compiles:


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 273 files in 160ms. No fixes applied.

> @mariozechner/pi-agent-core@0.23.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 40 files in 41ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.23.2 check
> tsgo --noEmit && tsc -p tsconfig.examples.json

> @mariozechner/pi-mom@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 20ms. No fixes applied.

> @mariozechner/pi@0.23.2 check
> biome check --write .

Checked 10 files in 18ms. No fixes applied.

> @mariozechner/pi-proxy@0.23.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 26 files in 29ms. No fixes applied.

> @mariozechner/pi-web-ui@0.23.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.11.2 check
> tsc --noEmit


All checks pass. Let me summarize what I fixed in hooks.md:

1. tool_call event: Clarified it has no timeout (user prompts can take any time), and that custom tools are also intercepted
2. Error handling: Fixed the claim about tool_call timeout - it's only errors that block, not timeouts
3. Internals: Clarified timeout behavior and that errors in emit() are caught vs. errors in emitToolCall() propagate

The hooks.md is now accurate. Let me check if there's anything else that might need updating. Let me also check the README.md hooks section to make sure it's consistent.

Let me check if the README hooks section needs updates too:

bash

grep -A 60 "### Hooks" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md | head -65


### Hooks

Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. Use them to:

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

**Hook locations:**
- Global: `~/.pi/agent/hooks/*.ts`
- Project: `.pi/hooks/*.ts`
- CLI: `--hook <path>` (for debugging)

**Quick example** (permission gate):

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

export default function (pi: HookAPI) {
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) {
      const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string);
      if (!ok) return { block: true, reason: "Blocked by user" };
    }
    return undefined;
  });
}
```

**Sending messages from hooks:**

Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.

```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) => {
    if (event.reason !== "start") return;
    fs.watch("/tmp/trigger.txt", () => {
      const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
      if (content) pi.send(content);
    });
  });
}
```

See [Hooks Documentation](docs/hooks.md) for full API reference.

See [examples/hooks/](examples/hooks/) for working examples including permission gates, git checkpointing, and path protection.

### Custom Tools

Custom tools extend pi with new capabilities beyond the built-in tools. They are TypeScript modules that define tools with optional custom TUI rendering.

**Tool locations:**
- Global: `~/.pi/agent/tools/*.ts`
- Project: `.pi/tools/*.ts`


The README hooks section looks good and accurate. It doesn't make any incorrect claims about timeouts. It's a good summary that points to the full docs for details.

Now let me summarize what I fixed in hooks.md:

The README hooks section looks good and accurate.

**Summary of hooks.md fixes:**

1. **tool_call event**: Clarified it has no timeout (user prompts can take any time), and that custom tools are also intercepted
2. **Error handling**: Fixed the incorrect claim about tool_call timeouts - only errors block, not timeouts
3. **Internals**: Clarified that errors in `emit()` are caught and logged, while errors in `emitToolCall()` propagate and block

Ready for custom-tools.md or rpc.md?


`hookTimeout`: Timeout in milliseconds for non-interactive hook operations (default: 30000)

so what's this then?


The user is pointing out that the docs mention `hookTimeout` in the settings.json configuration, which does set a timeout. Let me trace through the code to understand how this works.

Looking at runner.ts:
- `emit()` uses `this.timeout` (which defaults to `DEFAULT_TIMEOUT = 30000`)
- `emitToolCall()` has NO timeout

So the `hookTimeout` setting controls the timeout for `emit()` which handles all events EXCEPT `tool_call`. The `tool_call` event uses `emitToolCall()` which has no timeout.

So the docs are correct about:
- `hookTimeout` being configurable
- It being for "non-interactive hook operations"

The key insight is that `tool_call` is considered "interactive" (because hooks might prompt the user with `ctx.ui.select()`, `ctx.ui.confirm()`, etc.), so it has no timeout. All other events (`session`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `tool_result`, `branch`) use the timeout.

So my earlier fix was correct - I said "Other events have a timeout (default 30s)" which is accurate. The `hookTimeout` setting controls this.

Actually, let me re-read what I wrote in the error handling section to make sure it's accurate:

"- Other events have a timeout (default 30s); timeout errors are logged but don't block"

This is correct. The `hookTimeout` setting (default 30000ms = 30s) applies to all events except `tool_call`.

So the user is asking what hookTimeout is for - it's for events OTHER than tool_call. My docs are correct, but maybe I should clarify that hookTimeout doesn't apply to tool_call events.

You're right, let me trace through:

- `hookTimeout` setting → passed to `HookRunner` constructor → `this.timeout`
- `emit()` (used for `session`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `tool_result`, `branch`) → uses `this.timeout`
- `emitToolCall()` (used only for `tool_call`) → NO timeout

So `hookTimeout` applies to all events EXCEPT `tool_call`. The `tool_call` event intentionally has no timeout because hooks might prompt the user with `ctx.ui.select()`, `ctx.ui.confirm()`, etc.

My fix was correct - "Other events have a timeout (default 30s)" - but I could clarify that `hookTimeout` doesn't apply to `tool_call`. Should I add that clarification to the settings section?


yes


Let me update the hooks.md to clarify that hookTimeout doesn't apply to tool_call events.

edit

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

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 244 characters to 321 characters.

     ...
  32 }
  33 ```
  34
  35 - `hooks`: Additional hook file paths (supports `~` expansion)
- 36 - `hookTimeout`: Timeout in milliseconds for non-interactive hook operations (default: 30000)
+ 36 - `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.
  37
  38 ## Writing a Hook
  39
  40 A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.
     ...


Let me verify the changes pass the check.


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 273 files in 200ms. No fixes applied.

> @mariozechner/pi-agent-core@0.23.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 40 files in 41ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.23.2 check
> tsgo --noEmit && tsc -p tsconfig.examples.json

> @mariozechner/pi-mom@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 16ms. No fixes applied.

> @mariozechner/pi@0.23.2 check
> biome check --write .

Checked 10 files in 19ms. No fixes applied.

> @mariozechner/pi-proxy@0.23.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 26 files in 31ms. No fixes applied.

> @mariozechner/pi-web-ui@0.23.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.11.2 check
> tsc --noEmit


All checks pass. Ready for the next doc.

Done. Ready for custom-tools.md or rpc.md?


// event.sessionFile: string | null - current session file
should probably explain when this can be null


Good point - the user is saying we should explain when `ctx.sessionFile` can be null in the hooks documentation. Looking at the code, it's null when running with `--no-session` flag (ephemeral mode where sessions aren't saved).

Let me update the hooks.md to clarify this.

```typescript
pi.on("session", async (event, ctx) => {
  // event.entries: SessionEntry[] - all session entries
  // event.sessionFile: string | null - current session file
  // event.previousSessionFile: string | null - previous session file
  // event.reason: "start" | "switch" | "clear"
});
```

```typescript
pi.on("session", async (event, ctx) => {
  // event.entries: SessionEntry[] - all session entries
  // event.sessionFile: string | null - current session file (null with --no-session)
  // event.previousSessionFile: string | null - previous session file
  // event.reason: "start" | "switch" | "clear"
});
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 298 characters to 323 characters.

     ...
 114
 115 ```typescript
 116 pi.on("session", async (event, ctx) => {
 117   // event.entries: SessionEntry[] - all session entries
-118   // event.sessionFile: string | null - current session file
+118   // event.sessionFile: string | null - current session file (null with --no-session)
 119   // event.previousSessionFile: string | null - previous session file
 120   // event.reason: "start" | "switch" | "clear"
 121 });
 122 ```
     ...


Let me also check if there's a similar comment about ctx.sessionFile that should be updated:

Also update the `ctx.sessionFile` section:

### ctx.sessionFile

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

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

### ctx.sessionFile

Path to the current session file, or `null` when running with `--no-session` (ephemeral mode).

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 178 characters to 205 characters.

     ...
 269 ```
 270
 271 ### ctx.sessionFile
 272
-273 Path to the session file, or `null` if running with `--no-session`.
+273 Path to the current session file, or `null` when running with `--no-session` (ephemeral mode).
 274
 275 ```typescript
 276 if (ctx.sessionFile) {
 277   console.log(`Session: ${ctx.sessionFile}`);
     ...


Good, clarified both places where sessionFile being null is mentioned.


// event.toolResults: AppMessage[]

are those really AppMessage? not ToolResult?


Good catch. Let me check what the actual type is in the code.

bash

grep -A 10 "TurnEndEvent" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts


export interface TurnEndEvent {
	type: "turn_end";
	turnIndex: number;
	message: AppMessage;
	toolResults: AppMessage[];
}

/**
 * Event data for tool_call event.
 * Fired before a tool is executed. Hooks can block execution.
 */
--
	| TurnEndEvent
	| ToolCallEvent
	| ToolResultEvent
	| BranchEvent;

// ============================================================================
// Event Results
// ============================================================================

/**
 * Return type for tool_call event handlers.
--
	on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
	on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
	on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
	on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;

	/**
	 * Send a message to the agent.
	 * If the agent is streaming, the message is queued.
	 * If the agent is idle, a new agent loop is started.
	 */
	send(text: string, attachments?: Attachment[]): void;


The type is `AppMessage[]`. Let me check what AppMessage is from the pi-agent-core package to see if it's a union type that includes tool results:

grep -A 5 "export type AppMessage" /Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts


export type AppMessage =
	| AssistantMessage
	| UserMessageWithAttachments
	| Message // Includes ToolResultMessage
	| CustomMessages[keyof CustomMessages];


So `AppMessage` is a union type that includes `Message`, which includes `ToolResultMessage`. The docs say `AppMessage[]` which is technically correct, but it would be more helpful to say they're specifically tool result messages. Let me check what the actual runtime values are - they're likely `ToolResultMessage` instances.

Looking at the code, `toolResults` in `TurnEndEvent` contains the tool results from that turn. They're typed as `AppMessage[]` but in practice they're `ToolResultMessage` instances.

The docs could be more precise. Instead of just saying `AppMessage[]`, it could say "tool result messages" or similar to be clearer about what's actually in there.

The type is `AppMessage[]` which is a union that includes `ToolResultMessage`. But it would be clearer to say what they actually are:

edit

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

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 165 characters to 203 characters.

     ...
 164
 165 pi.on("turn_end", async (event, ctx) => {
 166   // event.turnIndex: number
 167   // event.message: AppMessage - assistant's response
-168   // event.toolResults: AppMessage[]
+168   // event.toolResults: AppMessage[] - tool result messages from this turn
 169 });
 170 ```
 171
 172 ### tool_call
     ...


That's better - it clarifies what's in the array without being overly technical about the exact type.


why isn't it toolresultmessage?


The user is asking why the type isn't `ToolResultMessage[]` instead of `AppMessage[]`. Let me check where this is set and if there's a reason it's typed as `AppMessage[]` instead of the more specific type.

Good question. Let me check where it's populated:

bash

grep -r "toolResults" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --include="*.ts" | grep -v "node_modules" | head -20


/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:	toolResultsMap: Map<string, ToolResultMessage>;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:		toolResultsMap: new Map(),
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:					data.toolResultsMap.set(toolResult.toolCallId, toolResult);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:		toolResultsMap: new Map(),
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:				data.toolResultsMap.set(toolResult.toolCallId, toolResult);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:				const toolResult = toolResultsMap.get(content.id);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts:					messagesHtml += formatMessage(event.message, data.toolResultsMap);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts:	toolResults: AppMessage[];
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:	toolResults: number;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:				toolResults: event.toolResults,
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:		const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:			toolResults,
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts:		info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;


Let me check in agent-session.ts where toolResults is populated for the turn_end event:

grep -B 10 -A 5 "toolResults: event.toolResults" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts


				type: "turn_start",
				turnIndex: this._turnIndex,
				timestamp: Date.now(),
			};
			await this._hookRunner.emit(hookEvent);
		} else if (event.type === "turn_end") {
			const hookEvent: TurnEndEvent = {
				type: "turn_end",
				turnIndex: this._turnIndex,
				message: event.message,
				toolResults: event.toolResults,
			};
			await this._hookRunner.emit(hookEvent);
			this._turnIndex++;
		}
	}


Let me check where `event.toolResults` comes from - it's from an AgentEvent. Let me check the agent package:

grep -A 10 "turn_end" /Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts | head -15


	| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }
	// Message lifecycle - emitted for user, assistant, and toolResult messages
	| { type: "message_start"; message: AppMessage }
	// Only emitted for assistant messages during streaming
	| { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent }
	| { type: "message_end"; message: AppMessage }
	// Tool execution lifecycle
	| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
	| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
	| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };


So the type is `AppMessage[]` in the agent package's type definition. This is the source of truth. It could probably be more specific (`ToolResultMessage[]`) but that would require changing the agent package types.

The user has a good point - it probably SHOULD be `ToolResultMessage[]` since that's what's actually in there. But changing it would require:
1. Updating the agent package types
2. Making sure nothing breaks

This seems like something that could be improved but is outside the scope of just documentation fixes. Should I note this as a potential improvement or just document what it currently is?

I think the right answer is: the docs should reflect reality (AppMessage[]), but we could open an issue to consider making the type more specific. For now, the docs saying "tool result messages from this turn" is accurate about what's actually in there, even if the type is broader.

Let me ask the user what they want to do.

You're right, it should probably be `ToolResultMessage[]`. The type comes from the agent package:

```typescript
// packages/agent/src/types.ts
| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }
```

It's typed as `AppMessage[]` but only ever contains tool result messages. Should I fix the type in the agent package, or just note it as a future improvement?


change it to ToolResultMessage, do a npm run check in root dir


The user wants me to change the type from AppMessage[] to ToolResultMessage[] in the agent package. Let me find the exact location and make the change.


import type {
	AgentTool,
	AssistantMessage,
	AssistantMessageEvent,
	Message,
	Model,
	UserMessage,
} from "@mariozechner/pi-ai";

/**
 * Attachment type definition.
 * Processing is done by consumers (e.g., document extraction in web-ui).
 */
export interface Attachment {
	id: string;
	type: "image" | "document";
	fileName: string;
	mimeType: string;
	size: number;
	content: string; // base64 encoded (without data URL prefix)
	extractedText?: string; // For documents
	preview?: string; // base64 image preview
}

/**
 * Thinking/reasoning level for models that support it.
 * Note: "xhigh" is only supported by OpenAI codex-max models.
 */
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";

/**
 * User message with optional attachments.
 */
export type UserMessageWithAttachments = UserMessage & { attachments?: Attachment[] };

/**
 * Extensible interface for custom app messages.
 * Apps can extend via declaration merging:
 *
 * @example
 * ```typescript
 * declare module "@mariozechner/agent" {
 *   interface CustomMessages {
 *     artifact: ArtifactMessage;
 *     notification: NotificationMessage;
 *   }
 * }
 * ```
 */
export interface CustomMessages {
	// Empty by default - apps extend via declaration merging
}

/**
 * AppMessage: Union of LLM messages + attachments + custom messages.
 * This abstraction allows apps to add custom message types while maintaining
 * type safety and compatibility with the base LLM messages.
 */
export type AppMessage =
	| AssistantMessage
	| UserMessageWithAttachments
	| Message // Includes ToolResultMessage
	| CustomMessages[keyof CustomMessages];

/**
 * Agent state containing all configuration and conversation data.
 */
export interface AgentState {
	systemPrompt: string;
	model: Model<any>;
	thinkingLevel: ThinkingLevel;
	tools: AgentTool<any>[];
	messages: AppMessage[]; // Can include attachments + custom message types
	isStreaming: boolean;
	streamMessage: Message | null;
	pendingToolCalls: Set<string>;
	error?: string;
}

/**
 * Events emitted by the Agent for UI updates.
 * These events provide fine-grained lifecycle information for messages, turns, and tool executions.
 */
export type AgentEvent =
	// Agent lifecycle
	| { type: "agent_start" }
	| { type: "agent_end"; messages: AppMessage[] }
	// Turn lifecycle - a turn is one assistant response + any tool calls/results
	| { type: "turn_start" }
	| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }
	// Message lifecycle - emitted for user, assistant, and toolResult messages
	| { type: "message_start"; message: AppMessage }
	// Only emitted for assistant messages during streaming
	| { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent }
	| { type: "message_end"; message: AppMessage }
	// Tool execution lifecycle
	| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
	| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
	| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };


I need to:
1. Import ToolResultMessage from @mariozechner/pi-ai
2. Change the type from AppMessage[] to ToolResultMessage[]

Let me check what's exported from pi-ai to make sure ToolResultMessage is available:

grep "ToolResultMessage" /Users/badlogic/workspaces/pi-mono/packages/ai/src/types.ts | head -5


export interface ToolResultMessage<TDetails = any> {
export type Message = UserMessage | AssistantMessage | ToolResultMessage;


Good, ToolResultMessage is exported from pi-ai. Now let me update the types.ts file in the agent package:

edit

import type {
	AgentTool,
	AssistantMessage,
	AssistantMessageEvent,
	Message,
	Model,
	UserMessage,
} from "@mariozechner/pi-ai";

import type {
	AgentTool,
	AssistantMessage,
	AssistantMessageEvent,
	Message,
	Model,
	ToolResultMessage,
	UserMessage,
} from "@mariozechner/pi-ai";


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts. Changed 130 characters to 150 characters.

     ...
   3 	AssistantMessage,
   4 	AssistantMessageEvent,
   5 	Message,
   6 	Model,
+  7 	ToolResultMessage,
   7 	UserMessage,
   8 } from "@mariozechner/pi-ai";
   9
  10 /**
     ...


| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }

| { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] }


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts. Changed 70 characters to 77 characters.

     ...
  87 	| { type: "agent_start" }
  88 	| { type: "agent_end"; messages: AppMessage[] }
  89 	// Turn lifecycle - a turn is one assistant response + any tool calls/results
  90 	| { type: "turn_start" }
- 91 	| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }
+ 91 	| { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] }
  92 	// Message lifecycle - emitted for user, assistant, and toolResult messages
  93 	| { type: "message_start"; message: AppMessage }
  94 	// Only emitted for assistant messages during streaming
  95 	| { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent }
     ...


Now I need to update the hooks types.ts in coding-agent to match:

Now update the hooks types to match:

bash

grep -n "toolResults" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts


124:	toolResults: AppMessage[];


I need to change this to ToolResultMessage[] and add the import. Let me check what's already imported:


/**
 * Hook system types.
 *
 * Hooks are TypeScript modules that can subscribe to agent lifecycle events
 * and interact with the user via UI primitives.
 */

import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { SessionEntry } from "../session-manager.js";

// ============================================================================
// Execution Context
// ============================================================================

/**


I need to add ToolResultMessage to the import from @mariozechner/pi-agent-core or from @mariozechner/pi-ai. Let me check if it's re-exported from pi-agent-core:


(no output)

Command exited with code 1


ToolResultMessage is not re-exported from pi-agent-core. I need to import it from @mariozechner/pi-ai:

edit

import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { SessionEntry } from "../session-manager.js";

import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import type { SessionEntry } from "../session-manager.js";


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts. Changed 133 characters to 195 characters.

     ...
   5  * and interact with the user via UI primitives.
   6  */
   7
   8 import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
+  9 import type { ToolResultMessage } from "@mariozechner/pi-ai";
   9 import type { SessionEntry } from "../session-manager.js";
  10
  11 // ============================================================================
  12 // Execution Context
     ...


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts. Changed 27 characters to 34 characters.

     ...
 121 export interface TurnEndEvent {
 122 	type: "turn_end";
 123 	turnIndex: number;
 124 	message: AppMessage;
-125 	toolResults: AppMessage[];
+125 	toolResults: ToolResultMessage[];
 126 }
 127
 128 /**
 129  * Event data for tool_call event.
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 273 files in 155ms. No fixes applied.

> @mariozechner/pi-agent-core@0.23.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 40 files in 36ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.23.2 check
> tsgo --noEmit && tsc -p tsconfig.examples.json

src/core/agent-session.ts(266,5): error TS2322: Type 'AppMessage[]' is not assignable to type 'ToolResultMessage<any>[]'.
  Type 'AppMessage' is not assignable to type 'ToolResultMessage<any>'.
    Type 'AssistantMessage' is missing the following properties from type 'ToolResultMessage<any>': toolCallId, toolName, isError
npm error Lifecycle script `check` failed with error:
npm error code 2
npm error path /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error workspace @mariozechner/pi-coding-agent@0.23.2
npm error location /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error command failed
npm error command sh -c tsgo --noEmit && tsc -p tsconfig.examples.json

> @mariozechner/pi-mom@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.23.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.23.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.23.2 check
> biome check --write . && tsgo --noEmit

Checked 26 files in 26ms. No fixes applied.

> @mariozechner/pi-web-ui@0.23.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.11.2 check
> tsc --noEmit

Command exited with code 2