There's a type error in agent-session.ts. I need to fix that - it's assigning AppMessage[] to ToolResultMessage[]. Let me check that line:

bash

sed -n '260,275p' /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts


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

	/**
	 * Subscribe to agent events.
	 * Session persistence is handled internally (saves messages on message_end).


The problem is that `event.toolResults` comes from the agent package's AgentEvent, which should now be `ToolResultMessage[]`. Let me check what type the agent package is exporting for that event:

grep -B 5 -A 5 "turn_end" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts | head -20


						const s = new Set(this._state.pendingToolCalls);
						s.delete(ev.toolCallId);
						this._state.pendingToolCalls = s;
						break;
					}
					case "turn_end": {
						if (ev.message.role === "assistant" && ev.message.errorMessage) {
							this._state.error = ev.message.errorMessage;
						}
						break;
					}


Let me check where the turn_end event is emitted from to see what type toolResults actually is:

grep -n "turn_end" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


336:					case "turn_end": {


Let me look for where turn_end events are created:

grep -rn "type: \"turn_end\"" /Users/badlogic/workspaces/pi-mono/packages/agent/src/


/Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts:91:	| { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] }


That's just the type definition. Let me find where turn_end events are actually emitted in the agent loop:

grep -rn "turn_end" /Users/badlogic/workspaces/pi-mono/packages/agent/src/ | grep -v "types.ts"


/Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts:336:					case "turn_end": {


The turn_end event seems to come from somewhere else. Let me check for yield statements or event emissions:

grep -n "yield.*turn" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


(no output)

Command exited with code 1


Let me check for where AgentEvent is created/yielded:

grep -n "yield" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts | head -20


(no output)


Let me look at the agent.ts file more carefully to understand how it works:

grep -n "AgentEvent" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts | head -10


4:import type { AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "./types.js";
74:	private listeners = new Set<(e: AgentEvent) => void>();
94:	subscribe(fn: (e: AgentEvent) => void): () => void {
299:	private async _processEvents(events: AsyncIterable<AgentEvent>) {
348:				this.emit(ev as AgentEvent);
401:	private emit(e: AgentEvent) {


Let me see where events are created by looking at _processEvents:

sed -n '299,360p' /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


	private async _processEvents(events: AsyncIterable<AgentEvent>) {
		const model = this._state.model!;
		const generatedMessages: AppMessage[] = [];
		let partial: AppMessage | null = null;

		try {
			for await (const ev of events) {
				switch (ev.type) {
					case "message_start": {
						partial = ev.message as AppMessage;
						this._state.streamMessage = ev.message as Message;
						break;
					}
					case "message_update": {
						partial = ev.message as AppMessage;
						this._state.streamMessage = ev.message as Message;
						break;
					}
					case "message_end": {
						partial = null;
						this._state.streamMessage = null;
						this.appendMessage(ev.message as AppMessage);
						generatedMessages.push(ev.message as AppMessage);
						break;
					}
					case "tool_execution_start": {
						const s = new Set(this._state.pendingToolCalls);
						s.add(ev.toolCallId);
						this._state.pendingToolCalls = s;
						break;
					}
					case "tool_execution_end": {
						const s = new Set(this._state.pendingToolCalls);
						s.delete(ev.toolCallId);
						this._state.pendingToolCalls = s;
						break;
					}
					case "turn_end": {
						if (ev.message.role === "assistant" && ev.message.errorMessage) {
							this._state.error = ev.message.errorMessage;
						}
						break;
					}
					case "agent_end": {
						this._state.streamMessage = null;
						break;
					}
				}

				this.emit(ev as AgentEvent);
			}

			// Handle any remaining partial message
			if (partial && partial.role === "assistant" && partial.content.length > 0) {
				const onlyEmpty = !partial.content.some(
					(c) =>
						(c.type === "thinking" && c.thinking.trim().length > 0) ||
						(c.type === "text" && c.text.trim().length > 0) ||
						(c.type === "toolCall" && c.name.trim().length > 0),
				);
				if (!onlyEmpty) {
					this.appendMessage(partial as AppMessage);


It seems events come from `events` which is an `AsyncIterable<AgentEvent>`. Let me find where this iterable is created - likely in an `agentLoop` or similar function:

grep -n "agentLoop" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


grep -n "async \*" /Users/badlogic/workspaces/pi-mono/packages/agent/src/*.ts


grep -n "_processEvents" /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


231:		await this._processEvents(events);
242:		await this._processEvents(events);
299:	private async _processEvents(events: AsyncIterable<AgentEvent>) {


sed -n '220,250p' /Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts


		await this._runAgentLoopContinue();
	}

	/**
	 * Internal: Run the agent loop with a new user message.
	 */
	private async _runAgentLoop(userMessage: AppMessage) {
		const { llmMessages, cfg } = await this._prepareRun();

		const events = this.transport.run(llmMessages, userMessage as Message, cfg, this.abortController!.signal);

		await this._processEvents(events);
	}

	/**
	 * Internal: Continue the agent loop from current context.
	 */
	private async _runAgentLoopContinue() {
		const { llmMessages, cfg } = await this._prepareRun();

		const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal);

		await this._processEvents(events);
	}

	/**
	 * Prepare for running the agent loop.
	 */
	private async _prepareRun() {
		const model = this._state.model;
		if (!model) {


So the events come from `this.transport.run()` or `this.transport.continue()`. Let me find the transport implementation:

find /Users/badlogic/workspaces/pi-mono/packages/agent/src -name "*.ts" -exec grep -l "turn_end" {} \;


/Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts
/Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts


The turn_end event must be created somewhere. Let me check the agent-transport:


agent.ts
index.ts
transports
types.ts


grep -rn "turn_end" /Users/badlogic/workspaces/pi-mono/packages/agent/src/transports/


(no output)

Command exited with code 1


Hmm, turn_end isn't in the transports. Let me search the entire agent package more broadly:

grep -rn '"turn_end"' /Users/badlogic/workspaces/pi-mono/packages/


/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/core/agent-session.js:165:        else if (event.type === "turn_end") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/core/agent-session.js:167:                type: "turn_end",
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/core/hooks/types.d.ts:101:    type: "turn_end";
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/core/hooks/types.d.ts:191:    on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/docs/rpc.md:592:  "type": "turn_end",
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/docs/hooks.md:165:pi.on("turn_end", async (event, ctx) => {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/docs/hooks.md:584:     -> hookRunner.emit({ type: "turn_end", ... })
Binary file /Users/badlogic/workspaces/pi-mono/packages/coding-agent/dist/pi matches
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/rpc.md:592:  "type": "turn_end",
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md:165:pi.on("turn_end", async (event, ctx) => {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md:587:     -> hookRunner.emit({ type: "turn_end", ... })
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts:122:	type: "turn_end";
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts:237:	on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:261:		} else if (event.type === "turn_end") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts:263:				type: "turn_end",
/Users/badlogic/workspaces/pi-mono/packages/web-ui/dist/agent/agent.js:200:                    case "turn_end": {
/Users/badlogic/workspaces/pi-mono/packages/web-ui/src/agent/agent.ts:265:					case "turn_end": {
/Users/badlogic/workspaces/pi-mono/packages/agent/dist/types.d.ts:75:    type: "turn_end";
/Users/badlogic/workspaces/pi-mono/packages/agent/dist/agent.js:280:                    case "turn_end": {
/Users/badlogic/workspaces/pi-mono/packages/agent/src/agent.ts:336:					case "turn_end": {
/Users/badlogic/workspaces/pi-mono/packages/agent/src/types.ts:91:	| { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] }
/Users/badlogic/workspaces/pi-mono/packages/ai/test/agent.test.ts:65:			case "turn_end":
/Users/badlogic/workspaces/pi-mono/packages/ai/test/agent.test.ts:459:			expect(eventTypes).toContain("turn_end");
/Users/badlogic/workspaces/pi-mono/packages/ai/dist/agent/types.d.ts:49:    type: "turn_end";
/Users/badlogic/workspaces/pi-mono/packages/ai/dist/agent/agent-loop.js:83:            stream.push({ type: "turn_end", message, toolResults: [] });
/Users/badlogic/workspaces/pi-mono/packages/ai/dist/agent/agent-loop.js:98:        stream.push({ type: "turn_end", message, toolResults: toolResults });
/Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/types.ts:74:	| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
/Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/agent-loop.ts:122:			stream.push({ type: "turn_end", message, toolResults: [] });
/Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/agent-loop.ts:139:		stream.push({ type: "turn_end", message, toolResults: toolResults });


I see! The turn_end event is created in `/Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/agent-loop.ts`. Let me check that file:

sed -n '115,145p' /Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/agent-loop.ts


		// Stream assistant response
		const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
		newMessages.push(message);

		if (message.stopReason === "error" || message.stopReason === "aborted") {
			// Stop the loop on error or abort
			stream.push({ type: "turn_end", message, toolResults: [] });
			stream.push({ type: "agent_end", messages: newMessages });
			stream.end(newMessages);
			return;
		}

		// Check for tool calls
		const toolCalls = message.content.filter((c) => c.type === "toolCall");
		hasMoreToolCalls = toolCalls.length > 0;

		const toolResults: ToolResultMessage[] = [];
		if (hasMoreToolCalls) {
			// Execute tool calls
			toolResults.push(...(await executeToolCalls(currentContext.tools, message, signal, stream)));
			currentContext.messages.push(...toolResults);
			newMessages.push(...toolResults);
		}
		stream.push({ type: "turn_end", message, toolResults: toolResults });

		// Get queued messages after turn completes
		queuedMessages = (await config.getQueuedMessages?.()) || [];
	}

	stream.push({ type: "agent_end", messages: newMessages });


Good, I can see that `toolResults` is already `ToolResultMessage[]` in the ai package. Let me also check the types in the ai package:

grep -A 3 "turn_end" /Users/badlogic/workspaces/pi-mono/packages/ai/src/agent/types.ts


	| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
	// Emitted when the agent has completed all its turns. All messages from every turn are
	// contained in messages, which can be appended to the context
	| { type: "agent_end"; messages: AgentContext["messages"] };


Good, the ai package already has `ToolResultMessage[]` for the turn_end event. The problem is in the agent package's types.ts which was using `AppMessage[]` and we just changed it to `ToolResultMessage[]`.

The error is that in agent-session.ts, it's receiving an event from the agent package (which should have `ToolResultMessage[]`), but somehow it's getting `AppMessage[]`.

Let me check what type the agent-session is expecting:

sed -n '1,30p' /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts


/**
 * AgentSession - Core abstraction for agent lifecycle and session management.
 *
 * This class is shared between all run modes (interactive, print, rpc).
 * It encapsulates:
 * - Agent state access
 * - Event subscription with automatic session persistence
 * - Model and thinking level management
 * - Compaction (manual and auto)
 * - Bash execution
 * - Session switching and branching
 *
 * Modes use this class and add their own I/O layer on top.
 */

import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai";
import { isContextOverflow } from "@mariozechner/pi-ai";
import { getModelsPath } from "../config.js";
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html.js";
import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
import type { BashExecutionMessage } from "./messages.js";
import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { loadSessionFromEntries, type SessionManager } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.js";
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";


I see - the agent-session imports AgentEvent from @mariozechner/pi-agent-core (which is the agent package). The event coming in has `toolResults: AppMessage[]` but we're trying to convert it to `TurnEndEvent` which now has `toolResults: ToolResultMessage[]`.

The problem is that the `AgentEvent` type in the agent package (which is imported as @mariozechner/pi-agent-core) now has `ToolResultMessage[]`, but the code might still be typed somewhere as `AppMessage[]`.

Wait, I just changed it to `ToolResultMessage[]` so it should be compatible. Let me check if there's a build issue - maybe we need to rebuild the agent package first.

Actually, looking at the error again:```
src/core/agent-session.ts(266,5): error TS2322: Type 'AppMessage[]' is not assignable to type 'ToolResultMessage<any>[]'.
```

It says line 266, let me look at that specific line:

sed -n '262,270p' /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts


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


The issue is that `event.toolResults` comes from `AgentEvent` in the agent package. The TypeScript compiler sees that the AgentEvent type still has `AppMessage[]` because we changed the source but the .d.ts files haven't been regenerated yet.Let me check if there's a dist/types that needs to be rebuilt or if tsgo is looking at the wrong type definitions:

cat /Users/badlogic/workspaces/pi-mono/packages/agent/dist/types.d.ts | grep -A 3 "turn_end"


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


The dist/types.d.ts still has `AppMessage[]`. We need to rebuild the agent package to regenerate the type definitions. Let me do that:

The dist files are stale. Need to rebuild the agent package first:

cd /Users/badlogic/workspaces/pi-mono/packages/agent && npm run build 2>&1


> @mariozechner/pi-agent-core@0.23.2 build
> tsgo -p tsconfig.build.json


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 43ms. 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 22ms. 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 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 now. Let me also update the docs to reflect the correct type:

Now update the docs to reflect the correct type:

edit

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

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 203 characters to 202 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[] - tool result messages from this turn
+168   // event.toolResults: ToolResultMessage[] - tool results from this turn
 169 });
 170 ```
 171
 172 ### tool_call
     ...


All done. Now let me summarize what was done:

1. Changed `toolResults` type from `AppMessage[]` to `ToolResultMessage[]` in:
   - packages/agent/src/types.ts
   - packages/coding-agent/src/core/hooks/types.ts

2. Added `ToolResultMessage` import to both files

3. Rebuilt the agent package to regenerate .d.ts files

4. Updated hooks.md to reflect the correct type

Done. Fixed the type in:
- `packages/agent/src/types.ts`
- `packages/coding-agent/src/core/hooks/types.ts`
- `docs/hooks.md`


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

Checked 273 files in 145ms. 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 37ms. 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 17ms. 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 4ms. No fixes applied.

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

Checked 26 files in 25ms. 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


ok, hooks.md looks good, do we need to update the Hooks section in README.md? maybe the intro should list example hooks? and we should reference the examples/hooks dir somewhere?


re intro example hooks, just what they can be used for concretely


Let me check the current README hooks section and the examples/hooks directory to see what's there.

bash

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


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


total 40
drwxr-xr-x@ 7 badlogic  staff   224 Dec 17 16:12 .
drwxr-xr-x@ 4 badlogic  staff   128 Dec 17 16:12 ..
-rw-r--r--@ 1 badlogic  staff   886 Dec 17 16:12 file-trigger.ts
-rw-r--r--@ 1 badlogic  staff  1250 Dec 17 16:12 git-checkpoint.ts
-rw-r--r--@ 1 badlogic  staff  1018 Dec 17 16:12 permission-gate.ts
-rw-r--r--@ 1 badlogic  staff   797 Dec 17 16:12 protected-paths.ts
-rw-r--r--@ 1 badlogic  staff  2151 Dec 17 16:12 README.md


The README hooks section already looks good:
1. It lists concrete use cases at the top (block dangerous commands, checkpoint code, protect paths, modify tool output, inject messages)
2. It has a quick example
3. It already references `examples/hooks/` at the bottom

The examples/hooks directory has:
- file-trigger.ts
- git-checkpoint.ts
- permission-gate.ts
- protected-paths.ts
- README.md

The README section already mentions "permission gates, git checkpointing, and path protection" which matches the examples.

I think the README hooks section is already good. The intro already lists concrete use cases. The examples are already referenced.

Let me tell the user this.

The README hooks section already looks good:

1. **Intro lists concrete use cases:**
   - Block dangerous commands (permission gates)
   - Checkpoint code state (git stash)
   - Protect paths (block writes to .env, etc.)
   - Modify tool output
   - Inject messages from external sources

2. **Already references examples/hooks/:**
   > See [examples/hooks/](examples/hooks/) for working examples including permission gates, git checkpointing, and path protection.

The examples directory has: `permission-gate.ts`, `git-checkpoint.ts`, `protected-paths.ts`, `file-trigger.ts`

I think it's already in good shape. Anything specific you'd like to add or change?


could repead in hooks.md after the first paragraph. should do the same for skills.md


The user wants me to add a reference to the examples directory in both hooks.md and skills.md, after the first paragraph. Let me do that.


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

[615 more lines in file. Use offset=21 to continue]


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

## When to Use Skills

| Need | Solution |
|------|----------|
| Always-needed context (conventions, commands) | AGENTS.md |
| User triggers a specific prompt template | Slash command |
| Additional tool directly callable by the LLM (like read/write/edit/bash) | Custom tool |
| On-demand capability package (workflows, scripts, setup) | Skill |

Skills are loaded when:
- The agent decides the task matches a skill's description
- The user explicitly asks to use a skill (e.g., "use the pdf skill to extract tables")

**Good skill examples:**
- Browser automation with helper scripts and CDP workflow
- Google Calendar CLI with setup instructions and usage patterns
- PDF processing with multiple tools and extraction patterns
- Speech-to-text transcription with API setup

**Not a good fit for skills:**
- "Always use TypeScript strict mode" → put in AGENTS.md

[160 more lines in file. Use offset=26 to continue]


Now let me add the examples reference after the first paragraph in hooks.md and the skill repositories reference after the intro section in skills.md.

edit

# 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

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.

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

## Hook Locations


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

   1 # Hooks
   2
   3 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.
   4
+  5 See [examples/hooks/](../examples/hooks/) for working examples including permission gates, git checkpointing, and path protection.
+  6
   5 ## Hook Locations
   6
   7 Hooks are automatically discovered from two locations:
   8
     ...


Now for skills.md, I should add a reference to the skill repositories after the intro. Let me check where that would fit best - probably after the "Not a good fit for skills" section.

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

## Skill Structure

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

For ready-to-use skills, see [Skill Repositories](#skill-repositories) at the end of this document.

## Skill Structure


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

     ...
  25 - "Always use TypeScript strict mode" → put in AGENTS.md
  26 - "Review my code" → make a slash command
  27 - Need user confirmation dialogs or custom TUI rendering → make a custom tool
  28
+ 29 For ready-to-use skills, see [Skill Repositories](#skill-repositories) at the end of this document.
+ 30
  29 ## Skill Structure
  30
  31 A skill is a directory with a `SKILL.md` file. Everything else is freeform. Example structure:
  32
     ...


Good. Now let me verify the changes by running npm run check:


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

Checked 273 files in 142ms. 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 35ms. 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 17ms. 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 3ms. No fixes applied.

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

Checked 26 files in 25ms. 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. I've added:1. hooks.md: Reference to examples/hooks/ after the first paragraph
2. skills.md: Reference to the Skill Repositories section after the intro

Ready for the next docs (custom-tools.md or rpc.md).

Done. Added:
- **hooks.md**: Link to `examples/hooks/` after intro
- **skills.md**: Link to Skill Repositories section after intro

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


not what i wanted. i wanted a prose list of examples, or a proper list: like permission gates, xyz for hooks, and something similar for skills


The user wants a list of concrete examples of what hooks/skills can be used for, similar to what's in the README:

For hooks (from README):
- 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)

For skills, I should list concrete examples like:
- Web search and content extraction
- Browser automation
- Google Calendar/Gmail/Drive integration
- PDF processing
- Speech-to-text transcription

Let me update both docs with proper lists.

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

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

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

**Example use cases:**
- Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.)
- Checkpoint code state (git stash at each turn, restore on `/branch`)
- Protect paths (block writes to `.env`, `node_modules/`, etc.)
- Modify tool output (filter or transform results before the LLM sees them)
- Inject messages from external sources (file watchers, webhooks, CI systems)

See [examples/hooks/](../examples/hooks/) for working implementations.


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

   1 # Hooks
   2
   3 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.
   4
-  5 See [examples/hooks/](../examples/hooks/) for working examples including permission gates, git checkpointing, and path protection.
+  5 **Example use cases:**
+  6 - Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.)
+  7 - Checkpoint code state (git stash at each turn, restore on `/branch`)
+  8 - Protect paths (block writes to `.env`, `node_modules/`, etc.)
+  9 - Modify tool output (filter or transform results before the LLM sees them)
+ 10 - Inject messages from external sources (file watchers, webhooks, CI systems)
   6
+ 12 See [examples/hooks/](../examples/hooks/) for working implementations.
+ 13
   7 ## Hook Locations
   8
   9 Hooks are automatically discovered from two locations:
  10
     ...


Now let me update skills.md with a similar list of concrete examples:

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

**Example use cases:**
- Web search and content extraction (Brave Search API)
- Browser automation via Chrome DevTools Protocol
- Google Calendar, Gmail, Drive integration
- PDF/DOCX processing and creation
- Speech-to-text transcription
- YouTube transcript extraction

See [Skill Repositories](#skill-repositories) for ready-to-use skills.


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

   1 # Skills
   2
   3 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.
   4
+  5 **Example use cases:**
+  6 - Web search and content extraction (Brave Search API)
+  7 - Browser automation via Chrome DevTools Protocol
+  8 - Google Calendar, Gmail, Drive integration
+  9 - PDF/DOCX processing and creation
+ 10 - Speech-to-text transcription
+ 11 - YouTube transcript extraction
+ 12
+ 13 See [Skill Repositories](#skill-repositories) for ready-to-use skills.
+ 14
   5 ## When to Use Skills
   6
   7 | Need | Solution |
   8 |------|----------|
     ...


Now I should remove the redundant reference I added earlier after "Not a good fit for skills":

Now remove the redundant reference I added earlier:

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

For ready-to-use skills, see [Skill Repositories](#skill-repositories) at the end of this document.

## Skill Structure

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

## Skill Structure


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

     ...
  35 - "Always use TypeScript strict mode" → put in AGENTS.md
  36 - "Review my code" → make a slash command
  37 - Need user confirmation dialogs or custom TUI rendering → make a custom tool
  38
- 39 For ready-to-use skills, see [Skill Repositories](#skill-repositories) at the end of this document.
- 40
  41 ## Skill Structure
  42
  43 A skill is a directory with a `SKILL.md` file. Everything else is freeform. Example structure:
  44
     ...


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

Checked 273 files in 157ms. 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 35ms. 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 17ms. 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 24ms. 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. I've now updated:hooks.md:
- Added list of example use cases (permission gates, git checkpointing, path protection, modify tool output, inject messages)
- Link to examples/hooksskills.md:
- Added list of example use cases (web search, browser automation, Google APIs, PDF/DOCX, transcription, YouTube)- Link to Skill Repositories section

Done. Added concrete example lists to both:

**hooks.md:**
- Block dangerous commands (permission gates)
- Checkpoint code state (git stash)
- Protect paths
- Modify tool output
- Inject messages from external sources

**skills.md:**
- Web search (Brave Search API)
- Browser automation (CDP)
- Google Calendar/Gmail/Drive
- PDF/DOCX processing
- Speech-to-text transcription
- YouTube transcript extraction

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


do we need a CHANGELOG.md entry for the ToolResultMEssage change? it's a breaking change


Good point. Changing `toolResults: AppMessage[]` to `toolResults: ToolResultMessage[]` is a breaking change for anyone who was using the type in their code (e.g., in hooks that handle `turn_end` events).

Let me check the CHANGELOG.md and add an entry under the [Unreleased] section.

Yes, good catch. Let me add it:


head: /Users/badlogic/workspaces/pi-mono/CHANGELOG.md: No such file or directory

Command exited with code 1


Let me find where the CHANGELOG is located:

bash

find /Users/badlogic/workspaces/pi-mono -name "CHANGELOG.md" -not -path "*/node_modules/*" 2>/dev/null


The change was in both the agent package and coding-agent package. Let me check if the agent package has a CHANGELOG. It doesn't seem to have one. The coding-agent package does.

Let me check the coding-agent CHANGELOG and add the entry there:


# Changelog

## [Unreleased]

## [0.23.2] - 2025-12-17

### Fixed

- Fixed Claude models via GitHub Copilot re-answering all previous prompts in multi-turn conversations. The issue was that assistant message content was sent as an array instead of a string, which Copilot's Claude adapter misinterpreted. Also added missing `Openai-Intent: conversation-edits` header and fixed `X-Initiator` logic to check for any assistant/tool message in history. ([#209](https://github.com/badlogic/pi-mono/issues/209))

- Detect image MIME type via file magic (read tool and `@file` attachments), not filename extension.

- Fixed markdown tables overflowing terminal width. Tables now wrap cell contents to fit available width instead of breaking borders mid-row. ([#206](https://github.com/badlogic/pi-mono/pull/206) by [@kim0](https://github.com/kim0))

## [0.23.1] - 2025-12-17

### Fixed

- Fixed TUI performance regression caused by Box component lacking render caching. Built-in tools now use Text directly (like v0.22.5), and Box has proper caching for custom tool rendering.

- Fixed custom tools failing to load from `~/.pi/agent/tools/` when pi is installed globally. Module imports (`@sinclair/typebox`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`) are now resolved via aliases.

## [0.23.0] - 2025-12-17

### Added

- **Custom tools**: Extend pi with custom tools written in TypeScript. Tools can provide custom TUI rendering, interact with users via `pi.ui` (select, confirm, input, notify), and maintain state across sessions via `onSession` callback. See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/). ([#190](https://github.com/badlogic/pi-mono/issues/190))

- **Hook and tool examples**: Added `examples/hooks/` and `examples/custom-tools/` with working examples. Examples are now bundled in npm and binary releases.

### Breaking Changes

- **Hooks**: Replaced `session_start` and `session_switch` events with unified `session` event. Use `event.reason` (`"start" | "switch" | "clear"`) to distinguish. Event now includes `entries` array for state reconstruction.

## [0.22.5] - 2025-12-17

### Fixed

- Fixed `--session` flag not saving sessions in print mode (`-p`). The session manager was never receiving events because no subscriber was attached.


I need to add an entry under [Unreleased] for the breaking change. The type change affects:1. `packages/agent/src/types.ts` - AgentEvent turn_end's toolResults changed from AppMessage[] to ToolResultMessage[]2. `packages/coding-agent/src/core/hooks/types.ts` - TurnEndEvent's toolResults changed from AppMessage[] to ToolResultMessage[]

edit

# Changelog

## [Unreleased]

### Breaking Changes

- **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.

## [0.23.2] - 2025-12-17


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md. Changed 54 characters to 293 characters.

   1 # Changelog
   2
   3 ## [Unreleased]
   4
+  5 ### Breaking Changes
+  6
+  7 - **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.
+  8
   5 ## [0.23.2] - 2025-12-17
   6
   7 ### Fixed
   8
     ...


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

Checked 273 files in 158ms. 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 37ms. 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 22ms. No fixes applied.

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

Checked 10 files in 20ms. 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 34ms. 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


Done. Added the breaking change to the CHANGELOG. Ready for the next doc.

Added to CHANGELOG.md under [Unreleased]:

```markdown
### Breaking Changes

- **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.
```

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


commit what we have first, and add a note about the sys prompt changes, and cleaned up docs


The user wants me to commit the changes we've made so far. Let me first add a note to the CHANGELOG about the system prompt changes and documentation cleanup, then commit everything.

## [Unreleased]

### Breaking Changes

- **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.

## [Unreleased]

### Changed

- Improved system prompt documentation section with clearer pointers to specific doc files for custom models, themes, skills, hooks, custom tools, and RPC.

- Cleaned up documentation: `theme.md` (added missing color tokens), `skills.md` (rewrote with better framing and examples), `hooks.md` (fixed timeout/error handling docs, added examples).

### Breaking Changes

- **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md. Changed 254 characters to 614 characters.

   1 # Changelog
   2
   3 ## [Unreleased]
   4
+  5 ### Changed
+  6
+  7 - Improved system prompt documentation section with clearer pointers to specific doc files for custom models, themes, skills, hooks, custom tools, and RPC.
+  8
+  9 - Cleaned up documentation: `theme.md` (added missing color tokens), `skills.md` (rewrote with better framing and examples), `hooks.md` (fixed timeout/error handling docs, added examples).
+ 10
   5 ### Breaking Changes
   6
   7 - **Hooks**: `turn_end` event's `toolResults` type changed from `AppMessage[]` to `ToolResultMessage[]`. If you have hooks that handle `turn_end` events and explicitly type the results, update your type annotations.
   8
     ...


Now let me check what files have been changed and commit them:

bash

cd /Users/badlogic/workspaces/pi-mono && git status


On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   .github/workflows/build-binaries.yml

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   packages/agent/src/types.ts
	modified:   packages/coding-agent/CHANGELOG.md
	modified:   packages/coding-agent/README.md
	deleted:    packages/coding-agent/docs/compaction-new.md
	deleted:    packages/coding-agent/docs/compaction-strategies.ts
	deleted:    packages/coding-agent/docs/compaction.md
	deleted:    packages/coding-agent/docs/gemini.md
	modified:   packages/coding-agent/docs/hooks.md
	modified:   packages/coding-agent/docs/skills.md
	modified:   packages/coding-agent/docs/theme.md
	deleted:    packages/coding-agent/docs/truncation.md
	deleted:    packages/coding-agent/docs/undercompaction.md
	modified:   packages/coding-agent/src/core/hooks/types.ts
	modified:   packages/coding-agent/src/core/system-prompt.ts


There are some deleted files and a staged change to the workflow file. Let me unstage the workflow file first and then stage all our changes:

cd /Users/badlogic/workspaces/pi-mono && git restore --staged .github/workflows/build-binaries.yml && git add packages/agent/src/types.ts packages/coding-agent/CHANGELOG.md packages/coding-agent/README.md packages/coding-agent/docs/hooks.md packages/coding-agent/docs/skills.md packages/coding-agent/docs/theme.md packages/coding-agent/src/core/hooks/types.ts packages/coding-agent/src/core/system-prompt.ts


(no output)


Now let me check the status again and commit:


On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   packages/agent/src/types.ts
	modified:   packages/coding-agent/CHANGELOG.md
	modified:   packages/coding-agent/README.md
	modified:   packages/coding-agent/docs/hooks.md
	modified:   packages/coding-agent/docs/skills.md
	modified:   packages/coding-agent/docs/theme.md
	modified:   packages/coding-agent/src/core/hooks/types.ts
	modified:   packages/coding-agent/src/core/system-prompt.ts

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	deleted:    packages/coding-agent/docs/compaction-new.md
	deleted:    packages/coding-agent/docs/compaction-strategies.ts
	deleted:    packages/coding-agent/docs/compaction.md
	deleted:    packages/coding-agent/docs/gemini.md
	deleted:    packages/coding-agent/docs/truncation.md
	deleted:    packages/coding-agent/docs/undercompaction.md


Good. There are some deleted files that aren't part of our changes (they look like old/stale docs). Let me commit what we have:

cd /Users/badlogic/workspaces/pi-mono && git commit -m "Improve system prompt docs, clean up theme/skills/hooks docs, fix toolResults type

- System prompt: clearer pointers to specific doc files
- theme.md: added thinkingXhigh, bashMode tokens, fixed Theme class methods
- skills.md: rewrote with better framing, examples, and skill repositories
- hooks.md: fixed timeout/error handling docs, added custom tool interception note
- Breaking: turn_end event toolResults changed from AppMessage[] to ToolResultMessage[]"


Running formatting, linting, and type checking...

> 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 44ms. 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 22ms. 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 25ms. 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 pre-commit checks passed!
[main 5e5bdad] Improve system prompt docs, clean up theme/skills/hooks docs, fix toolResults type
 8 files changed, 232 insertions(+), 141 deletions(-)


Committed. Now ready for custom-tools.md or rpc.md.

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


custom-tools.md, as before need to establish what they are good for


The user wants me to review custom-tools.md and make sure it clearly establishes what custom tools are good for, similar to what we did with skills and hooks.

Let me first read the current custom-tools.md and check the implementation to make sure it's accurate.


# Custom Tools

Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI.

## Quick Start

Create a file `~/.pi/agent/tools/hello.ts`:

```typescript
import { Type } from "@sinclair/typebox";
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";

const factory: CustomToolFactory = (pi) => ({
  name: "hello",
  label: "Hello",
  description: "A simple greeting tool",
  parameters: Type.Object({
    name: Type.String({ description: "Name to greet" }),
  }),

  async execute(toolCallId, params) {
    return {
      content: [{ type: "text", text: `Hello, ${params.name}!` }],
      details: { greeted: params.name },
    };
  },
});

export default factory;
```

The tool is automatically discovered and available in your next pi session.

## Tool Locations

| Location | Scope | Auto-discovered |
|----------|-------|-----------------|
| `~/.pi/agent/tools/*.ts` | Global (all projects) | Yes |
| `.pi/tools/*.ts` | Project-local | Yes |
| `settings.json` `customTools` array | Configured paths | Yes |
| `--tool <path>` CLI flag | One-off/debugging | No |

**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.

**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).

## Available Imports

Custom tools can import from these packages (automatically resolved by pi):

| Package | Purpose |
|---------|---------|
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |

Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.

## Tool Definition

```typescript
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";

const factory: CustomToolFactory = (pi) => ({
  name: "my_tool",
  label: "My Tool",
  description: "What this tool does (be specific for LLM)",
  parameters: Type.Object({
    // Use StringEnum for string enums (Google API compatible)
    action: StringEnum(["list", "add", "remove"] as const),
    text: Type.Optional(Type.String()),
  }),

  async execute(toolCallId, params, signal, onUpdate) {
    // signal - AbortSignal for cancellation
    // onUpdate - Callback for streaming partial results
    return {
      content: [{ type: "text", text: "Result for LLM" }],
      details: { /* structured data for rendering */ },
    };
  },

  // Optional: Session lifecycle callback
  onSession(event) { /* reconstruct state from entries */ },

  // Optional: Custom rendering
  renderCall(args, theme) { /* return Component */ },
  renderResult(result, options, theme) { /* return Component */ },

  // Optional: Cleanup on session end
  dispose() { /* save state, close connections */ },
});

export default factory;
```

**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.

## ToolAPI Object

The factory receives a `ToolAPI` object (named `pi` by convention):

```typescript
interface ToolAPI {
  cwd: string;  // Current working directory
  exec(command: string, args: string[]): Promise<ExecResult>;
  ui: {
    select(title: string, options: string[]): Promise<string | null>;
    confirm(title: string, message: string): Promise<boolean>;
    input(title: string, placeholder?: string): Promise<string | null>;
    notify(message: string, type?: "info" | "warning" | "error"): void;
  };
  hasUI: boolean;  // false in --print or --mode rpc
}
```

Always check `pi.hasUI` before using UI methods.

## Session Lifecycle

Tools can implement `onSession` to react to session changes:

```typescript
interface ToolSessionEvent {
  entries: SessionEntry[];      // All session entries
  sessionFile: string | null;   // Current session file
  previousSessionFile: string | null;  // Previous session file
  reason: "start" | "switch" | "branch" | "clear";
}
```

**Reasons:**
- `start`: Initial session load on startup
- `switch`: User switched to a different session (`/session`)
- `branch`: User branched from a previous message (`/branch`)
- `clear`: User cleared the session (`/clear`)

### State Management Pattern

Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.

```typescript
interface MyToolDetails {
  items: string[];
}

const factory: CustomToolFactory = (pi) => {
  // In-memory state
  let items: string[] = [];

  // Reconstruct state from session entries
  const reconstructState = (event: ToolSessionEvent) => {
    items = [];
    for (const entry of event.entries) {
      if (entry.type !== "message") continue;
      const msg = entry.message;
      if (msg.role !== "toolResult") continue;
      if (msg.toolName !== "my_tool") continue;

      const details = msg.details as MyToolDetails | undefined;
      if (details) {
        items = details.items;
      }
    }
  };

  return {
    name: "my_tool",
    label: "My Tool",
    description: "...",
    parameters: Type.Object({ ... }),

    onSession: reconstructState,

    async execute(toolCallId, params) {
      // Modify items...
      items.push("new item");

      return {
        content: [{ type: "text", text: "Added item" }],
        // Store current state in details for reconstruction
        details: { items: [...items] },
      };
    },
  };
};
```

This pattern ensures:
- When user branches, state is correct for that point in history
- When user switches sessions, state matches that session
- When user clears, state resets

## Custom Rendering

Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.

### How It Works

Tool output is wrapped in a `Box` component that handles:
- Padding (1 character horizontal, 1 line vertical)
- Background color based on state (pending/success/error)

Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding.

### renderCall

Renders the tool call (before/during execution):

```typescript
renderCall(args, theme) {
  let text = theme.fg("toolTitle", theme.bold("my_tool "));
  text += theme.fg("muted", args.action);
  if (args.text) {
    text += " " + theme.fg("dim", `"${args.text}"`);
  }
  return new Text(text, 0, 0);
}
```

Called when:
- Tool call starts (may have partial args during streaming)
- Args are updated during streaming

### renderResult

Renders the tool result:

```typescript
renderResult(result, { expanded, isPartial }, theme) {
  const { details } = result;

  // Handle streaming/partial results
  if (isPartial) {
    return new Text(theme.fg("warning", "Processing..."), 0, 0);
  }

  // Handle errors
  if (details?.error) {
    return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
  }

  // Normal result
  let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");

  // Support expanded view (Ctrl+O)
  if (expanded && details?.items) {
    for (const item of details.items) {
      text += "\n" + theme.fg("dim", `  ${item}`);
    }
  }

  return new Text(text, 0, 0);
}
```

**Options:**
- `expanded`: User pressed Ctrl+O to expand
- `isPartial`: Result is from `onUpdate` (streaming), not final

### Best Practices

1. **Use `Text` with padding `(0, 0)`** - The Box handles padding
2. **Use `\n` for multi-line content** - Not multiple Text components
3. **Handle `isPartial`** - Show progress during streaming
4. **Support `expanded`** - Show more detail when user requests
5. **Use theme colors** - For consistent appearance
6. **Keep it compact** - Show summary by default, details when expanded

### Theme Colors

```typescript
// Foreground
theme.fg("toolTitle", text)   // Tool names
theme.fg("accent", text)      // Highlights
theme.fg("success", text)     // Success
theme.fg("error", text)       // Errors
theme.fg("warning", text)     // Warnings
theme.fg("muted", text)       // Secondary text
theme.fg("dim", text)         // Tertiary text
theme.fg("toolOutput", text)  // Output content

// Styles
theme.bold(text)
theme.italic(text)
```

### Fallback Behavior

If `renderCall` or `renderResult` is not defined or throws an error:
- `renderCall`: Shows tool name
- `renderResult`: Shows raw text output from `content`

## Execute Function

```typescript
async execute(toolCallId, args, signal, onUpdate) {
  // Type assertion for params (TypeBox schema doesn't flow through)
  const params = args as { action: "list" | "add"; text?: string };

  // Check for abort
  if (signal?.aborted) {
    return { content: [...], details: { status: "aborted" } };
  }

  // Stream progress
  onUpdate?.({
    content: [{ type: "text", text: "Working..." }],
    details: { progress: 50 },
  });

  // Return final result
  return {
    content: [{ type: "text", text: "Done" }],  // Sent to LLM
    details: { data: result },  // For rendering only
  };
}
```

## Multiple Tools from One File

Return an array to share state between related tools:

```typescript
const factory: CustomToolFactory = (pi) => {
  // Shared state
  let connection = null;

  return [
    { name: "db_connect", ... },
    { name: "db_query", ... },
    {
      name: "db_close",
      dispose() { connection?.close(); }
    },
  ];
};
```

## Examples

See [`examples/custom-tools/todo.ts`](../examples/custom-tools/todo.ts) for a complete example with:
- `onSession` for state reconstruction
- Custom `renderCall` and `renderResult`
- Proper branching support via details storage

Test with:
```bash
pi --tool packages/coding-agent/examples/custom-tools/todo.ts
```


This is pretty comprehensive. But it's missing a clear intro explaining what custom tools are good for and when to use them vs. alternatives (like skills, hooks, etc.).

Let me check the implementation to make sure the docs are accurate, then add a better intro section.

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


hello.ts
question.ts
README.md
todo.ts


Let me check the types.ts for custom tools to understand the API:


/**
 * Custom tool types.
 *
 * Custom tools are TypeScript modules that define additional tools for the agent.
 * They can provide custom rendering for tool calls and results in the TUI.
 */

import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { HookUIContext } from "../hooks/types.js";
import type { SessionEntry } from "../session-manager.js";

/** Alias for clarity */
export type ToolUIContext = HookUIContext;

export interface ExecResult {
	stdout: string;
	stderr: string;
	code: number;
}

/** API passed to custom tool factory (stable across session changes) */
export interface ToolAPI {
	/** Current working directory */
	cwd: string;
	/** Execute a command */
	exec(command: string, args: string[]): Promise<ExecResult>;
	/** UI methods for user interaction (select, confirm, input, notify) */
	ui: ToolUIContext;
	/** Whether UI is available (false in print/RPC mode) */
	hasUI: boolean;
}

/** Session event passed to onSession callback */
export interface SessionEvent {
	/** 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" | "branch" | "clear";
}

/** Rendering options passed to renderResult */
export interface RenderResultOptions {
	/** Whether the result view is expanded */
	expanded: boolean;
	/** Whether this is a partial/streaming result */
	isPartial: boolean;
}

/** Custom tool with optional lifecycle and rendering methods */
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
	extends AgentTool<TParams, TDetails> {
	/** Called on session start/switch/branch/clear - use to reconstruct state from entries */
	onSession?: (event: SessionEvent) => void | Promise<void>;
	/** Custom rendering for tool call display - return a Component */
	renderCall?: (args: Static<TParams>, theme: Theme) => Component;
	/** Custom rendering for tool result display - return a Component */
	renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
	/** Called when session ends - cleanup resources */
	dispose?: () => Promise<void> | void;
}

/** Factory function that creates a custom tool or array of tools */
export type CustomToolFactory = (
	pi: ToolAPI,
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;

/** Loaded custom tool with metadata */
export interface LoadedCustomTool {
	/** Original path (as specified) */
	path: string;
	/** Resolved absolute path */
	resolvedPath: string;
	/** The tool instance */
	tool: CustomAgentTool;
}

/** Result from loading custom tools */
export interface CustomToolsLoadResult {
	tools: LoadedCustomTool[];
	errors: Array<{ path: string; error: string }>;
	/** Update the UI context for all loaded tools. Call when mode initializes. */
	setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
}


Good. The docs look accurate. Now I need to add a better intro section explaining what custom tools are good for.Looking at the current comparison table we've established:| Need | Solution |
|------|----------|
| Always-needed context (conventions, commands) | AGENTS.md |
| User triggers a specific prompt template | Slash command |
| Additional tool directly callable by the LLM (like read/write/edit/bash) | Custom tool |
| On-demand capability package (workflows, scripts, setup) | Skill |

Custom tools are good for:
- Adding new callable tools that the LLM can invoke with parameters
- When you need user confirmation dialogs or input prompts during execution
- Custom TUI rendering (progress, structured output)
- Maintaining state across tool calls (e.g., todo list, connection pool)
- When you need to return structured data to the LLM

Let me also look at the example tools to understand use cases better:


/**
 * Question Tool - Let the LLM ask the user a question with options
 */

import { Type } from "@sinclair/typebox";
import { Text } from "@mariozechner/pi-tui";
import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";

interface QuestionDetails {
	question: string;
	options: string[];
	answer: string | null;
}

const QuestionParams = Type.Object({
	question: Type.String({ description: "The question to ask the user" }),
	options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
});

const factory: CustomToolFactory = (pi) => {
	const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
		name: "question",
		label: "Question",
		description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
		parameters: QuestionParams,

		async execute(_toolCallId, params) {
			if (!pi.hasUI) {
				return {
					content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],


/**
 * Todo Tool - Demonstrates state management via session entries
 *
 * This tool stores state in tool result details (not external files),
 * which allows proper branching - when you branch, the todo state
 * is automatically correct for that point in history.
 *
 * The onSession callback reconstructs state by scanning past tool results.
 */

import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";

interface Todo {
	id: number;
	text: string;
	done: boolean;
}

// State stored in tool result details
interface TodoDetails {
	action: "list" | "add" | "toggle" | "clear";
	todos: Todo[];
	nextId: number;
	error?: string;
}

// Define schema separately for proper type inference


Now I have a clear picture. Let me update the custom-tools.md with a better intro:

Custom tools are good for:
1. Adding new tools the LLM can call directly (like read/write/edit/bash)
2. Prompting the user for input/confirmation during execution
3. Custom TUI rendering (progress indicators, structured output)
4. Maintaining state across calls (todo lists, connections, etc.)
5. Structured data that needs to survive branching

Let me also check if there are any discrepancies in the docs vs implementation.

Now let me update custom-tools.md with a proper intro:

edit

# Custom Tools

Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI.

## Quick Start

# Custom Tools

Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.

**Example use cases:**
- Ask the user questions with selectable options
- Maintain state across calls (todo lists, connection pools)
- Custom TUI rendering (progress indicators, structured output)
- Integrate external services with proper error handling
- Tools that need user confirmation before proceeding

**When to use custom tools vs. alternatives:**

| Need | Solution |
|------|----------|
| Always-needed context (conventions, commands) | AGENTS.md |
| User triggers a specific prompt template | Slash command |
| On-demand capability package (workflows, scripts, setup) | Skill |
| Additional tool directly callable by the LLM | **Custom tool** |

See [examples/custom-tools/](../examples/custom-tools/) for working examples.

## Quick Start


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/custom-tools.md. Changed 226 characters to 1018 characters.

   1 # Custom Tools
   2
-  3 Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI.
+  3 Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
   4
+  5 **Example use cases:**
+  6 - Ask the user questions with selectable options
+  7 - Maintain state across calls (todo lists, connection pools)
+  8 - Custom TUI rendering (progress indicators, structured output)
+  9 - Integrate external services with proper error handling
+ 10 - Tools that need user confirmation before proceeding
+ 11
+ 12 **When to use custom tools vs. alternatives:**
+ 13
+ 14 | Need | Solution |
+ 15 |------|----------|
+ 16 | Always-needed context (conventions, commands) | AGENTS.md |
+ 17 | User triggers a specific prompt template | Slash command |
+ 18 | On-demand capability package (workflows, scripts, setup) | Skill |
+ 19 | Additional tool directly callable by the LLM | **Custom tool** |
+ 20
+ 21 See [examples/custom-tools/](../examples/custom-tools/) for working examples.
+ 22
   5 ## Quick Start
   6
   7 Create a file `~/.pi/agent/tools/hello.ts`:
   8
     ...


Good. Now let me verify this looks good by running npm run check:


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

Checked 273 files in 208ms. 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 42ms. 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 23ms. 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 4ms. No fixes applied.

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

Checked 26 files in 35ms. 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


Now let me verify the rest of the doc is accurate against the implementation. Let me check for any potential issues:1. ToolAPI object - check if it matches types.ts
2. Session event - check if it matches
3. Custom rendering - check if it's accurate

Now let me verify the rest matches the implementation. Let me check the ToolAPI and SessionEvent:

bash

grep -A 15 "interface ToolAPI" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/custom-tools/types.ts


export interface ToolAPI {
	/** Current working directory */
	cwd: string;
	/** Execute a command */
	exec(command: string, args: string[]): Promise<ExecResult>;
	/** UI methods for user interaction (select, confirm, input, notify) */
	ui: ToolUIContext;
	/** Whether UI is available (false in print/RPC mode) */
	hasUI: boolean;
}

/** Session event passed to onSession callback */
export interface SessionEvent {
	/** All session entries (including pre-compaction history) */
	entries: SessionEntry[];
	/** Current session file path, or null in --no-session mode */


The ToolAPI in the docs matches the implementation. Let me also check the SessionEvent in the docs vs the implementation:The implementation has:- entries: SessionEntry[]
- sessionFile: string | null
- previousSessionFile: string | null
- reason: "start" | "switch" | "branch" | "clear"The docs have:```typescript
interface ToolSessionEvent {
  entries: SessionEntry[];      // All session entries
  sessionFile: string | null;   // Current session file
  previousSessionFile: string | null;  // Previous session file
  reason: "start" | "switch" | "branch" | "clear";
}
```

This looks accurate. The docs call it `ToolSessionEvent` while the actual type is just `SessionEvent`. Let me check if that's exported as `ToolSessionEvent`:


SessionEvent,


The export is `SessionEvent`, not `ToolSessionEvent`. Let me check what the docs say and fix if needed:


| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
interface ToolSessionEvent {
  const reconstructState = (event: ToolSessionEvent) => {


The docs reference `ToolSessionEvent` but the actual export is `SessionEvent`. Let me check the index.ts to see what's exported from the main package:

grep -E "SessionEvent|ToolSessionEvent" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/index.ts


	type AgentSessionEvent,
	type AgentSessionEventListener,
	SessionEvent as ToolSessionEvent,
	SessionEvent,


It's exported as both `SessionEvent` and `ToolSessionEvent`. So the docs are correct - `ToolSessionEvent` is a valid export. The alias is for backwards compatibility or clarity when importing from the custom tools perspective.The docs look accurate. Let me also check the README custom tools section to see if it needs updating:

Good, `ToolSessionEvent` is exported as an alias. Now check README custom tools section:

grep -A 50 "### Custom Tools" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md | head -55


### 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`
- CLI: `--tool <path>`
- Settings: `customTools` array in `settings.json`

**Quick example:**

```typescript
import { Type } from "@sinclair/typebox";
import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";

const factory: CustomToolFactory = (pi) => ({
  name: "greet",
  label: "Greeting",
  description: "Generate a greeting",
  parameters: Type.Object({
    name: Type.String({ description: "Name to greet" }),
  }),

  async execute(toolCallId, params) {
    return {
      content: [{ type: "text", text: `Hello, ${params.name}!` }],
      details: { greeted: params.name },
    };
  },
});

export default factory;
```

**Features:**
- Access to `pi.cwd`, `pi.exec()`, `pi.ui` (select/confirm/input dialogs)
- Session lifecycle via `onSession` callback (for state reconstruction)
- Custom rendering via `renderCall()` and `renderResult()` methods
- Streaming results via `onUpdate` callback
- Abort handling via `signal` parameter
- Cleanup via `dispose()` method
- Multiple tools from one factory (return an array)

See [Custom Tools Documentation](docs/custom-tools.md) for the full API reference, TUI component guide, and examples.

See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.

### Settings File

`~/.pi/agent/settings.json` stores persistent preferences:
--
### Custom Tools

Pi relies on CLI tools invoked via bash rather than MCP. Create a tool with a README:


I see there are two "Custom Tools" sections in the README. The first one is about TypeScript custom tools, and the second one is about CLI tools with README files (the bash-based approach). The second one should be renamed to avoid confusion.Let me check where exactly the second one is:

There are two "Custom Tools" sections in the README - one for TypeScript custom tools (correct) and one for CLI bash tools (confusing). Let me check the second one:

grep -n "### Custom Tools" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md


581:### Custom Tools
771:### Custom Tools


There are two "Custom Tools" sections at lines 581 and 771. Let me see what the second one is about:

sed -n '771,800p' /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md


### Custom Tools

Pi relies on CLI tools invoked via bash rather than MCP. Create a tool with a README:

`~/agent-tools/screenshot/README.md`:
```markdown
# Screenshot Tool
Takes a screenshot of your main display.

## Usage
```bash
screenshot.sh
```
Returns the path to the saved PNG.
```

`~/agent-tools/screenshot/screenshot.sh`:
```bash
#!/bin/bash
screencapture -x /tmp/screenshot-$(date +%s).png
ls -t /tmp/screenshot-*.png | head -1
```

Usage: "Read ~/agent-tools/screenshot/README.md and take a screenshot"

Reference tool READMEs in `AGENTS.md` to make them automatically available.

---

## Programmatic Usage


This second "Custom Tools" section is about the CLI tool pattern (bash scripts with READMEs), not about the TypeScript custom tools. This is confusing. It should be renamed to something like "CLI Tool Pattern" or "Bash-based Tools".Let me rename this section:

edit

### Custom Tools

Pi relies on CLI tools invoked via bash rather than MCP. Create a tool with a README:

### CLI Tool Pattern

Pi relies on CLI tools invoked via bash rather than MCP. Create a tool with a README:


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

     ...
 767 | `ls` | List directory contents. Includes dotfiles. |
 768
 769 Example: `--tools read,grep,find,ls` for code review without modification.
 770
-771 ### Custom Tools
+771 ### CLI Tool Pattern
 772
 773 Pi relies on CLI tools invoked via bash rather than MCP. Create a tool with a README:
 774
 775 `~/agent-tools/screenshot/README.md`:
     ...


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

Checked 273 files in 154ms. 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 38ms. 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 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 25ms. 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. The custom-tools.md changes are:1. Added a proper intro explaining what custom tools are2. Added example use cases list
3. Added comparison table with alternatives4. Added link to examples directory5. Fixed the README duplicate "Custom Tools" section by renaming the second one to "CLI Tool Pattern"

Let me summarize what was done.

Done. Changes to custom-tools.md:
- Added intro explaining what custom tools are (additional tools the LLM can call directly)
- Added example use cases (ask questions, maintain state, custom TUI, etc.)
- Added comparison table with alternatives
- Added link to examples directory

Also renamed the confusing duplicate "Custom Tools" section in README.md to "CLI Tool Pattern" (the bash-based approach).

Ready for rpc.md, or should we commit first?


## Available Imports

Custom tools can import from these packages (automatically resolved by pi):

| Package | Purpose |
|---------|---------|
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |

Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.

is this true for hooks as well? then we should document it. also the


The user is asking if hooks also have the same import resolution for packages like `@sinclair/typebox`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-ai`, `@mariozechner/pi-tui`.

Let me check the hooks loader to see how it resolves imports:

bash

grep -A 20 "createJiti\|jiti" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/loader.ts

grep -A 20 "createJiti\|jiti" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/custom-tools/loader.ts


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


 * Custom tool loader - loads TypeScript tool modules using jiti.
 */

import { spawn } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookUIContext } from "../hooks/types.js";
import type { CustomToolFactory, CustomToolsLoadResult, ExecResult, LoadedCustomTool, ToolAPI } from "./types.js";

// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);

// Lazily computed aliases - resolved at runtime to handle global installs
let _aliases: Record<string, string> | null = null;
function getAliases(): Record<string, string> {
	if (_aliases) return _aliases;

	const __dirname = path.dirname(fileURLToPath(import.meta.url));
	const packageIndex = path.resolve(__dirname, "../..", "index.js");

	_aliases = {
		"@mariozechner/pi-coding-agent": packageIndex,
		"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
		"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
		"@sinclair/typebox": require.resolve("@sinclair/typebox"),
--
 * Load a single tool module using jiti.
 */
async function loadTool(
	toolPath: string,
	cwd: string,
	sharedApi: ToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
	const resolvedPath = resolveToolPath(toolPath, cwd);

	try {
		// Create jiti instance for TypeScript/ESM loading
		// Use aliases to resolve package imports since tools are loaded from user directories
		// (e.g. ~/.pi/agent/tools) but import from packages installed with pi-coding-agent
		const jiti = createJiti(import.meta.url, {
			alias: getAliases(),
		});

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

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

		// Call factory with shared API
		const result = await factory(sharedApi);

		// Handle single tool or array of tools
		const toolsArray = Array.isArray(result) ? result : [result];

		const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({
			path: toolPath,
			resolvedPath,
			tool,
		}));

		return { tools: loadedTools, error: null };
	} catch (err) {


I see! Custom tools have aliases configured:```javascript
_aliases = {
  "@mariozechner/pi-coding-agent": packageIndex,
  "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),  "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
  "@sinclair/typebox": require.resolve("@sinclair/typebox"),
```

But hooks do NOT have these aliases - they just use plain jiti without aliases:```javascript
const jiti = createJiti(import.meta.url);
```

So for hooks, imports might fail if the packages aren't installed in the user's directory. The user is asking if hooks also support these imports. The answer is: not in the same way. Hooks rely on the packages being available through normal module resolution.

Let me check if this is documented in hooks.md and if we should add the aliases to hooks as well, or document the difference.Actually, looking at the hooks.md, it already mentions:> The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically.But this is only partially true - the jiti will resolve from the import.meta.url (which is the loader's URL), so it might work because pi-coding-agent is in the same node_modules. But it's not using explicit aliases like custom tools do.

Should we:1. Add aliases to hooks loader (for consistency)
2. Document the difference3. Document what's available for both

Let me ask the user what they want to do. Actually, they said "is this true for hooks as well?" - I should check and answer, and possibly fix if needed.

Looking at the code:- Custom tools: explicitly set up aliases for those 4 packages
- Hooks: no aliasesThis means hooks might fail to import these packages if they're not in the normal resolution path. We should probably add the same aliases to hooks for consistency.

Hooks don't have the same aliases. Custom tools loader has:

```javascript
const jiti = createJiti(import.meta.url, {
  alias: getAliases(),  // @mariozechner/pi-coding-agent, @mariozechner/pi-tui, @mariozechner/pi-ai, @sinclair/typebox
});
```

But hooks loader just uses:

```javascript
const jiti = createJiti(import.meta.url);  // No aliases
```

Should I add the same aliases to hooks loader for consistency? That way hooks could also import from `@mariozechner/pi-ai`, `@mariozechner/pi-tui`, `@sinclair/typebox` reliably.


yes


I'll add the same aliases to the hooks loader for consistency with custom tools.


/**
 * 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);
}


Now I need to add the same alias setup as custom tools. I'll need to:
1. Import `createRequire` from `node:module` and `fileURLToPath` from `node:url`
2. Add the `getAliases` function
3. Update `loadHook` to use aliases when creating jiti

edit

/**
 * 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";

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

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

// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);

// Lazily computed aliases - resolved at runtime to handle global installs
let _aliases: Record<string, string> | null = null;
function getAliases(): Record<string, string> {
	if (_aliases) return _aliases;

	const __dirname = path.dirname(fileURLToPath(import.meta.url));
	const packageIndex = path.resolve(__dirname, "../..", "index.js");

	_aliases = {
		"@mariozechner/pi-coding-agent": packageIndex,
		"@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"),
		"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
		"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
		"@sinclair/typebox": require.resolve("@sinclair/typebox"),
	};
	return _aliases;
}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/loader.ts. Changed 365 characters to 1264 characters.

     ...
   2  * Hook loader - loads TypeScript hook modules using jiti.
   3  */
   4
   5 import * as fs from "node:fs";
+  6 import { createRequire } from "node:module";
   6 import * as os from "node:os";
   7 import * as path from "node:path";
+  9 import { fileURLToPath } from "node:url";
   8 import type { Attachment } from "@mariozechner/pi-agent-core";
   9 import { createJiti } from "jiti";
  10 import { getAgentDir } from "../../config.js";
  11 import type { HookAPI, HookFactory } from "./types.js";
  12
+ 15 // Create require function to resolve module paths at runtime
+ 16 const require = createRequire(import.meta.url);
+ 17
+ 18 // Lazily computed aliases - resolved at runtime to handle global installs
+ 19 let _aliases: Record<string, string> | null = null;
+ 20 function getAliases(): Record<string, string> {
+ 21 	if (_aliases) return _aliases;
+ 22
+ 23 	const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ 24 	const packageIndex = path.resolve(__dirname, "../..", "index.js");
+ 25
+ 26 	_aliases = {
+ 27 		"@mariozechner/pi-coding-agent": packageIndex,
+ 28 		"@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"),
+ 29 		"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
+ 30 		"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
+ 31 		"@sinclair/typebox": require.resolve("@sinclair/typebox"),
+ 32 	};
+ 33 	return _aliases;
+ 34 }
+ 35
  13 /**
  14  * Generic handler function type.
  15  */
  16 type HandlerFn = (...args: unknown[]) => Promise<unknown>;
     ...