do you know how to create a custom tool?

 Yes! Custom tools (also called "skills") in pi are created by writing a SKILL.md file that describes the
 tool and provides instructions for using it.

 Let me check the documentation for the exact format:

well, that's wrong no? i suppose we need to review the system prompt


You're right, let me check the actual documentation:


# pi

A terminal-based coding agent with multi-model support, mid-session model switching, and a simple CLI for headless coding tasks.

Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-setup)).

## Table of Contents

- [Getting Started](#getting-started)
  - [Installation](#installation)
  - [Windows Setup](#windows-setup)
  - [API Keys](#api-keys)
  - [Quick Start](#quick-start)
- [Usage](#usage)
  - [Slash Commands](#slash-commands)
  - [Editor Features](#editor-features)
  - [Keyboard Shortcuts](#keyboard-shortcuts)
  - [Bash Mode](#bash-mode)
  - [Image Support](#image-support)
- [Sessions](#sessions)
  - [Session Management](#session-management)
  - [Context Compaction](#context-compaction)
  - [Branching](#branching)
- [Configuration](#configuration)
  - [Project Context Files](#project-context-files)
  - [Custom Models and Providers](#custom-models-and-providers)
  - [Themes](#themes)
  - [Custom Slash Commands](#custom-slash-commands)
  - [Skills](#skills)
  - [Hooks](#hooks)
  - [Custom Tools](#custom-tools)
  - [Settings File](#settings-file)
- [CLI Reference](#cli-reference)
- [Tools](#tools)
- [Programmatic Usage](#programmatic-usage)
- [Philosophy](#philosophy)
- [Development](#development)
- [License](#license)

---

## Getting Started

### Installation

**npm (recommended):**

```bash
npm install -g @mariozechner/pi-coding-agent
```

**Standalone binary:**

Download from [GitHub Releases](https://github.com/badlogic/pi-mono/releases):

| Platform | Archive |
|----------|---------|
| macOS Apple Silicon | `pi-darwin-arm64.tar.gz` |
| macOS Intel | `pi-darwin-x64.tar.gz` |
| Linux x64 | `pi-linux-x64.tar.gz` |
| Linux ARM64 | `pi-linux-arm64.tar.gz` |
| Windows x64 | `pi-windows-x64.zip` |

```bash
# macOS/Linux
tar -xzf pi-darwin-arm64.tar.gz
./pi

# Windows
unzip pi-windows-x64.zip
pi.exe
```

**macOS note:** The binary is unsigned. If blocked, run: `xattr -c ./pi`

**Build from source** (requires [Bun](https://bun.sh) 1.0+):

```bash
git clone https://github.com/badlogic/pi-mono.git
cd pi-mono && npm install
cd packages/coding-agent && npm run build:binary
./dist/pi
```

### Windows Setup

Pi requires a bash shell on Windows. Checked locations (in order):

1. Custom path from `~/.pi/agent/settings.json`
2. Git Bash (`C:\Program Files\Git\bin\bash.exe`)
3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)

For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.

**Custom shell path:**

```json
// ~/.pi/agent/settings.json
{
  "shellPath": "C:\\cygwin64\\bin\\bash.exe"
}
```

### API Keys

Set the environment variable for your provider:

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

The `/model` command only shows models for providers with configured API keys.

**OAuth (Claude Pro/Max subscribers):**

```bash
pi
/login  # Select "Anthropic (Claude Pro/Max)", authorize in browser
```

Tokens stored in `~/.pi/agent/oauth.json` (mode 0600). Use `/logout` to clear.

**GitHub Copilot:**

```bash
pi
/login  # Select "GitHub Copilot", authorize in browser
```

During login, you'll be prompted for an enterprise domain. Press Enter to use github.com, or enter your GitHub Enterprise Server domain (e.g., `github.mycompany.com`). All models are automatically enabled after login.

If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model, and click "Enable".

For enterprise users, check with your organization's Copilot administrator for model availability.

Tokens stored in `~/.pi/agent/oauth.json` (mode 0600). Use `/logout` to clear.

### Quick Start

```bash
export ANTHROPIC_API_KEY=sk-ant-...
pi
```

Then chat:

```
You: Create a simple Express server in src/server.ts
```

The agent reads, writes, and edits files, and executes commands via bash.

---

## Usage

### Slash Commands

| Command | Description |
|---------|-------------|
| `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
| `/thinking` | Adjust thinking level for reasoning models (off/minimal/low/medium/high) |
| `/queue` | Set message queue mode: one-at-a-time (default) or all-at-once |
| `/export [file]` | Export session to self-contained HTML |
| `/session` | Show session info: path, message counts, token usage, cost |
| `/changelog` | Display full version history |
| `/branch` | Create new conversation branch from a previous message |
| `/resume` | Switch to a different session (interactive selector) |
| `/login` | OAuth login for subscription-based models |
| `/logout` | Clear OAuth tokens |
| `/clear` | Clear context and start fresh session |
| `/copy` | Copy last agent message to clipboard |
| `/compact [instructions]` | Manually compact conversation context |
| `/autocompact` | Toggle automatic context compaction |
| `/theme` | Select color theme |
| `/show-images` | Toggle inline image display (supported terminals only) |

### Editor Features

**File reference (`@`):** Type `@` to fuzzy-search project files. Respects `.gitignore`.

**Path completion (Tab):** Complete relative paths, `../`, `~/`, etc.

**Drag & drop:** Drag files from your file manager into the terminal.

**Multi-line paste:** Pasted content is collapsed to `[paste #N <lines> lines]` but sent in full.

**Message queuing:** Submit messages while the agent is working. They queue and process based on `/queue` mode. Press Escape to abort and restore queued messages to editor.

### Keyboard Shortcuts

**Navigation:**

| Key | Action |
|-----|--------|
| Arrow keys | Move cursor / browse history (Up when empty) |
| Option+Left/Right | Move by word |
| Ctrl+A / Home | Start of line |
| Ctrl+E / End | End of line |

**Editing:**

| Key | Action |
|-----|--------|
| Enter | Send message |
| Shift+Enter / Alt+Enter | New line (Ctrl+Enter on WSL) |
| Ctrl+W / Option+Backspace | Delete word backwards |
| Ctrl+U | Delete to start of line |
| Ctrl+K | Delete to end of line |

**Other:**

| Key | Action |
|-----|--------|
| Tab | Path completion / accept autocomplete |
| Escape | Cancel autocomplete / abort streaming |
| Ctrl+C | Clear editor (first) / exit (second) |
| Shift+Tab | Cycle thinking level |
| Ctrl+P | Cycle models (scoped by `--models`) |
| Ctrl+O | Toggle tool output expansion |
| Ctrl+T | Toggle thinking block visibility |

### Bash Mode

Prefix commands with `!` to execute them and add output to context:

```
!ls -la
!git status
!cat package.json | jq '.dependencies'
```

Output streams in real-time. Press Escape to cancel. Large outputs truncate at 2000 lines / 50KB.

The output becomes part of your next prompt, formatted as:

```
Ran `ls -la`
```
<output here>
```
```

Run multiple commands before prompting; all outputs are included together.

### Image Support

**Attaching images:** Include image paths in your message:

```
You: What's in this screenshot? /path/to/image.png
```

Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`

**Inline rendering:** On terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, images in tool output are rendered inline. On unsupported terminals, a text placeholder is shown instead.

Toggle inline images with `/show-images` or set `terminal.showImages: false` in settings.

---

## Sessions

### Session Management

Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.

```bash
pi --continue      # Continue most recent session
pi -c              # Short form

pi --resume        # Browse and select from past sessions
pi -r              # Short form

pi --no-session    # Ephemeral mode (don't save)

pi --session /path/to/file.jsonl  # Use specific session file
```

### Context Compaction

Long sessions can exhaust context windows. Compaction summarizes older messages while keeping recent ones.

**Manual:** `/compact` or `/compact Focus on the API changes`

**Automatic:** Enable with `/autocompact`. When enabled, triggers in two cases:
- **Overflow recovery**: LLM returns context overflow error. Compacts and auto-retries.
- **Threshold maintenance**: Context exceeds `contextWindow - reserveTokens` after a successful turn. Compacts without retry.

When disabled, neither case triggers automatic compaction (use `/compact` manually if needed).

**How it works:**
1. Cut point calculated to keep ~20k tokens of recent messages
2. Messages before cut point are summarized
3. Summary replaces old messages as "context handoff"
4. Previous compaction summaries chain into new ones

**Configuration** (`~/.pi/agent/settings.json`):

```json
{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
```

> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point.

### Branching

Use `/branch` to explore alternative conversation paths:

1. Opens selector showing all your user messages
2. Select a message to branch from
3. Creates new session with history up to that point
4. Selected message placed in editor for modification

---

## Configuration

### Project Context Files

Pi loads `AGENTS.md` (or `CLAUDE.md`) files at startup in this order:

1. **Global:** `~/.pi/agent/AGENTS.md`
2. **Parent directories:** Walking up from current directory
3. **Current directory:** `./AGENTS.md`

Use these for:
- Project instructions and guidelines
- Common commands and workflows
- Architecture documentation
- Coding conventions
- Testing instructions

```markdown
# Common Commands
- npm run build: Build the project
- npm test: Run tests

# Code Style
- Use TypeScript strict mode
- Prefer async/await over promises
```

### Custom Models and Providers

Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:

```json
{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "apiKey": "OLLAMA_API_KEY",
      "api": "openai-completions",
      "models": [
        {
          "id": "llama-3.1-8b",
          "name": "Llama 3.1 8B (Local)",
          "reasoning": false,
          "input": ["text"],
          "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
          "contextWindow": 128000,
          "maxTokens": 32000
        }
      ]
    }
  }
}
```

**Supported APIs:** `openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`

**API key resolution:** The `apiKey` field is checked as environment variable name first, then used as literal value.

**API override:** Set `api` at provider level (default for all models) or model level (override per model).

**Custom headers:**

```json
{
  "providers": {
    "custom-proxy": {
      "baseUrl": "https://proxy.example.com/v1",
      "apiKey": "YOUR_API_KEY",
      "api": "anthropic-messages",
      "headers": {
        "User-Agent": "Mozilla/5.0 ...",
        "X-Custom-Auth": "token"
      },
      "models": [...]
    }
  }
}
```

**Authorization header:** Set `authHeader: true` to add `Authorization: Bearer <apiKey>` automatically.

**OpenAI compatibility (`compat` field):**

| Field | Description |
|-------|-------------|
| `supportsStore` | Whether provider supports `store` field |
| `supportsDeveloperRole` | Use `developer` vs `system` role |
| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |
| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |

**Live reload:** The file reloads each time you open `/model`. Edit during session; no restart needed.

**Model selection priority:**
1. CLI args (`--provider`, `--model`)
2. First from `--models` scope (new sessions only)
3. Restored from session (`--continue`, `--resume`)
4. Saved default from settings
5. First available model with valid API key

### Themes

Built-in themes: `dark` (default), `light`. Auto-detected on first run.

```bash
/theme  # Interactive selector
```

**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.

```bash
mkdir -p ~/.pi/agent/themes
cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
```

Select with `/theme`, then edit the file. Changes apply on save.

See [Theme Documentation](docs/theme.md) for all 44 color tokens.

**VS Code terminal fix:** Set `terminal.integrated.minimumContrastRatio` to `1` for accurate colors.

### Custom Slash Commands

Define reusable prompts as Markdown files:

**Locations:**
- Global: `~/.pi/agent/commands/*.md`
- Project: `.pi/commands/*.md`

**Format:**

```markdown
---
description: Review staged git changes
---
Review the staged changes (`git diff --cached`). Focus on:
- Bugs and logic errors
- Security issues
- Error handling gaps
```

Filename (without `.md`) becomes the command name. Description shown in autocomplete.

**Arguments:**

```markdown
---
description: Create a component
---
Create a React component named $1 with features: $@
```

Usage: `/component Button "onClick handler" "disabled support"`
- `$1` = `Button`
- `$@` = all arguments joined

**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)`

### Skills

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

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

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

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

**Format:**

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

# PDF Processing

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

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

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

**How it works:**

Skills are listed in the system prompt with descriptions:

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

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

**Disable skills:**

CLI: `pi --no-skills`

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

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

### Hooks

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

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

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

**Quick example** (permission gate):

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

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

**Sending messages from hooks:**

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

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

export default function (pi: HookAPI) {
  pi.on("session", async (event) => {
    if (event.reason !== "start") return;
    fs.watch("/tmp/trigger.txt", () => {
      const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
      if (content) pi.send(content);
    });
  });
}
```

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

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

### Custom Tools

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

**Tool locations:**
- Global: `~/.pi/agent/tools/*.ts`
- Project: `.pi/tools/*.ts`
- 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:

```json
{
  "theme": "dark",
  "shellPath": "C:\\path\\to\\bash.exe",
  "queueMode": "one-at-a-time",
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  },
  "skills": {
    "enabled": true
  },
  "retry": {
    "enabled": true,
    "maxRetries": 3,
    "baseDelayMs": 2000
  },
  "terminal": {
    "showImages": true
  }
}
```

**Retry settings:**
- `enabled`: Auto-retry on transient errors (overloaded, rate limit, 5xx). Default: `true`
- `maxRetries`: Maximum retry attempts. Default: `3`
- `baseDelayMs`: Base delay for exponential backoff (2s, 4s, 8s). Default: `2000`

**Terminal settings:**
- `showImages`: Render images inline in supported terminals. Default: `true`

---

## CLI Reference

```bash
pi [options] [@files...] [messages...]
```

### Options

| Option | Description |
|--------|-------------|
| `--provider <name>` | Provider: `anthropic`, `openai`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, or custom |
| `--model <id>` | Model ID |
| `--api-key <key>` | API key (overrides environment) |
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |
| `--append-system-prompt <text\|file>` | Append to system prompt |
| `--mode <mode>` | Output mode: `text`, `json`, `rpc` (implies `--print`) |
| `--print`, `-p` | Non-interactive: process prompt and exit |
| `--no-session` | Don't save session |
| `--session <path>` | Use specific session file |
| `--continue`, `-c` | Continue most recent session |
| `--resume`, `-r` | Select session to resume |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) |
| `--tools <tools>` | Comma-separated tool list (default: `read,bash,edit,write`) |
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
| `--hook <path>` | Load a hook file (can be used multiple times) |
| `--no-skills` | Disable skills discovery and loading |
| `--export <file> [output]` | Export session to HTML |
| `--help`, `-h` | Show help |
| `--version`, `-v` | Show version |

### File Arguments

Include files with `@` prefix:

```bash
pi @prompt.md "Answer this"
pi @screenshot.png "What's in this image?"
pi @requirements.md @design.png "Implement this"
```

Text files wrapped in `<file name="path">content</file>`. Images attached as base64.

### Examples

```bash
# Interactive mode
pi

# Interactive with initial prompt
pi "List all .ts files in src/"

# Non-interactive
pi -p "List all .ts files in src/"

# With files
pi -p @code.ts "Review this code"

# JSON event stream
pi --mode json "List files"

# RPC mode (headless)
pi --mode rpc --no-session

# Continue session
pi -c "What did we discuss?"

# Specific model
pi --provider openai --model gpt-4o "Help me refactor"

# Model cycling with thinking levels
pi --models sonnet:high,haiku:low

# Read-only mode
pi --tools read,grep,find,ls -p "Review the architecture"

# Export session
pi --export session.jsonl output.html
```

---

## Tools

### Default Tools

| Tool | Description |
|------|-------------|
| `read` | Read file contents. Images sent as attachments. Text: first 2000 lines, lines truncated at 2000 chars. Use offset/limit for large files. |
| `write` | Write/overwrite file. Creates parent directories. |
| `edit` | Replace exact text in file. Must match exactly including whitespace. Fails if text appears multiple times or not found. |
| `bash` | Execute command. Returns stdout/stderr. Optional `timeout` parameter. |

### Read-Only Tools

Available via `--tools` flag:

| Tool | Description |
|------|-------------|
| `grep` | Search file contents (regex or literal). Respects `.gitignore`. |
| `find` | Search for files by glob pattern. Respects `.gitignore`. |
| `ls` | List directory contents. Includes dotfiles. |

Example: `--tools read,grep,find,ls` for code review without modification.

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

### RPC Mode

For embedding pi in other applications:

```bash
pi --mode rpc --no-session
```

Send JSON commands on stdin:
```json
{"type":"prompt","message":"List all .ts files"}
{"type":"abort"}
```

See [RPC documentation](docs/rpc.md) for full protocol.

**Node.js/TypeScript:** Consider using `AgentSession` directly from `@mariozechner/pi-coding-agent` instead of subprocess. See [`src/core/agent-session.ts`](src/core/agent-session.ts) and [`src/modes/rpc/rpc-client.ts`](src/modes/rpc/rpc-client.ts).

### HTML Export

```bash
pi --export session.jsonl              # Auto-generated filename
pi --export session.jsonl output.html  # Custom filename
```

Works with both session files and streaming event logs from `--mode json`.

---

## Philosophy

Pi is opinionated about what it won't do. These are intentional design decisions.

### No MCP

Pi does not support MCP (Model Context Protocol). Instead, it relies on four core tools (read, write, edit, bash) and assumes the agent can invoke CLI tools or write them as needed.

CLI tools are simpler: any executable with a README works. No protocol overhead, no server management. The agent reads the README and uses bash.

See: [What if you don't need MCP?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)

### No Sub-Agents

If the agent needs to delegate, it can spawn `pi` via bash or write a custom tool. Built-in sub-agents transfer context poorly; information gets lost or misrepresented. For parallel work, run multiple `pi` sessions in different terminals.

### No Built-in To-Dos

To-do lists confuse models more than they help. For task tracking, use a file:

```markdown
# TODO.md
- [x] Implement authentication
- [ ] Write API docs
```

### No Planning Mode

Tell the agent to think through problems without modifying files. For persistent plans, write to a file:

```markdown
# PLAN.md
## Goal
Refactor auth to support OAuth
## Current Step
Working on authorization endpoints
```

### No Permission System (YOLO Mode)

Pi runs with full filesystem access and no permission prompts. Why:
- Permission systems add friction while being easily circumvented
- Pre-checking for "dangerous" patterns causes latency and false positives

**Risks:**
- Can read, write, delete anything with your user privileges
- Prompt injection via files or command output can influence behavior

**Mitigations:**
- Run in a container if uncomfortable
- Don't use on systems with sensitive data you can't afford to lose

### No Background Bash

Use `tmux` or similar. Bonus: you can watch the agent interact with CLIs and intervene if needed.

---

## Development

### Forking / Rebranding

Configure via `package.json`:

```json
{
  "piConfig": {
    "name": "pi",
    "configDir": ".pi"
  }
}
```

Change `name`, `configDir`, and `bin` field for your fork. Affects CLI banner, config paths, and environment variable names.

### Path Resolution

Three execution modes: npm install, standalone binary, tsx from source.

**Always use `src/paths.ts`** for package assets:

```typescript
import { getPackageDir, getThemeDir } from "./paths.js";
```

Never use `__dirname` directly for package assets.

### Debug Command

`/debug` (hidden) writes rendered lines with ANSI codes to `~/.pi/agent/pi-debug.log` for TUI debugging.

For architecture and contribution guidelines, see [DEVELOPMENT.md](./DEVELOPMENT.md).

---

## License

MIT

## See Also

- [@mariozechner/pi-ai](https://www.npmjs.com/package/@mariozechner/pi-ai): Core LLM toolkit
- [@mariozechner/pi-agent](https://www.npmjs.com/package/@mariozechner/pi-agent): Agent framework


The README has a "Custom Tools" section. Let me check if there's more detailed documentation:


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


Now I understand the full picture. Looking at the system prompt, I see the issue. The system prompt says:

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

This refers to the "lightweight" approach of just making bash scripts with READMEs. But there's also the proper **Custom Tools** feature (TypeScript modules in `~/.pi/agent/tools/`).

**So to answer your question:** Yes, there are two ways to create "custom tools":

1. **Lightweight/bash approach** (mentioned in system prompt): Create a CLI tool with a README, reference it in `AGENTS.md`, and the agent invokes it via bash.

2. **Custom Tools** (TypeScript modules): Create `.ts` files in `~/.pi/agent/tools/` or `.pi/tools/` with full API access, custom TUI rendering, session state management, etc.

The system prompt currently only mentions the lightweight approach. Should I update it to also mention the Custom Tools feature, or were you pointing out something else?


open the syspormpt .ts file in code for me so i can review


(no output)


Opened `system-prompt.ts` in VS Code.


ok, i think we need to make the documentation section a bit better. we need to tell the model (you)

- Your own documentation is here ${readmePath}
- Additional documentation can be found here: ${docsPath}
- If the users asks any of the following, then first read ${readmePath} in full, then:
  - custom model or provider, ${readmePath} is sufficient
  - custom theme: read docs/theme.md
  - skills: read docs/skills.md
  - hooks: read docs/hooks.md
  - custom tools: read docs/custom-tools.md
  - rpc: read docs/rpc.md

please update system-prompt.ts accordingly, keep it super concise. then we need to go through each of the mentioned .md files and check if we can improve them and if they actually still describe the current state of affairs (some might be outdated or miss new features)


/**
 * System prompt construction and project context loading
 */

import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
import { formatSkillsForPrompt, loadSkills } from "./skills.js";
import type { ToolName } from "./tools/index.js";

/** Tool descriptions for system prompt */
const toolDescriptions: Record<ToolName, string> = {
	read: "Read file contents",
	bash: "Execute bash commands (ls, grep, find, etc.)",
	edit: "Make surgical edits to files (find exact text and replace)",
	write: "Create or overwrite files",
	grep: "Search file contents for patterns (respects .gitignore)",
	find: "Find files by glob pattern (respects .gitignore)",
	ls: "List directory contents",
};

/** Resolve input as file path or literal string */
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
	if (!input) {
		return undefined;
	}

	if (existsSync(input)) {
		try {
			return readFileSync(input, "utf-8");
		} catch (error) {
			console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
			return input;
		}
	}

	return input;
}

/** Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
	const candidates = ["AGENTS.md", "CLAUDE.md"];
	for (const filename of candidates) {
		const filePath = join(dir, filename);
		if (existsSync(filePath)) {
			try {
				return {
					path: filePath,
					content: readFileSync(filePath, "utf-8"),
				};
			} catch (error) {
				console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
			}
		}
	}
	return null;
}

/**
 * Load all project context files in order:
 * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md
 * 2. Parent directories (top-most first) down to cwd
 * Each returns {path, content} for separate messages
 */
export function loadProjectContextFiles(): Array<{ path: string; content: string }> {
	const contextFiles: Array<{ path: string; content: string }> = [];

	// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/
	const globalContextDir = getAgentDir();
	const globalContext = loadContextFileFromDir(globalContextDir);
	if (globalContext) {
		contextFiles.push(globalContext);
	}

	// 2. Walk up from cwd to root, collecting all context files
	const cwd = process.cwd();
	const ancestorContextFiles: Array<{ path: string; content: string }> = [];

	let currentDir = cwd;
	const root = resolve("/");

	while (true) {
		const contextFile = loadContextFileFromDir(currentDir);
		if (contextFile) {
			// Add to beginning so we get top-most parent first
			ancestorContextFiles.unshift(contextFile);
		}

		// Stop if we've reached root
		if (currentDir === root) break;

		// Move up one directory
		const parentDir = resolve(currentDir, "..");
		if (parentDir === currentDir) break; // Safety check
		currentDir = parentDir;
	}

	// Add ancestor files in order (top-most → cwd)
	contextFiles.push(...ancestorContextFiles);

	return contextFiles;
}

export interface BuildSystemPromptOptions {
	customPrompt?: string;
	selectedTools?: ToolName[];
	appendSystemPrompt?: string;
	skillsEnabled?: boolean;
}

/** Build the system prompt with tools, guidelines, and context */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
	const { customPrompt, selectedTools, appendSystemPrompt, skillsEnabled = true } = options;
	const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
	const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");

	const now = new Date();
	const dateTime = now.toLocaleString("en-US", {
		weekday: "long",
		year: "numeric",
		month: "long",
		day: "numeric",
		hour: "2-digit",
		minute: "2-digit",
		second: "2-digit",
		timeZoneName: "short",
	});

	const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";

	if (resolvedCustomPrompt) {
		let prompt = resolvedCustomPrompt;

		if (appendSection) {
			prompt += appendSection;
		}

		// Append project context files
		const contextFiles = loadProjectContextFiles();
		if (contextFiles.length > 0) {
			prompt += "\n\n# Project Context\n\n";
			prompt += "The following project context files have been loaded:\n\n";
			for (const { path: filePath, content } of contextFiles) {
				prompt += `## ${filePath}\n\n${content}\n\n`;
			}
		}

		// Append skills section (only if read tool is available)
		const customPromptHasRead = !selectedTools || selectedTools.includes("read");
		if (skillsEnabled && customPromptHasRead) {
			const skills = loadSkills();
			prompt += formatSkillsForPrompt(skills);
		}

		// Add date/time and working directory last
		prompt += `\nCurrent date and time: ${dateTime}`;
		prompt += `\nCurrent working directory: ${process.cwd()}`;

		return prompt;
	}

	// Get absolute paths to documentation
	const readmePath = getReadmePath();
	const docsPath = getDocsPath();

	// Build tools list based on selected tools
	const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
	const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");

	// Build guidelines based on which tools are actually available
	const guidelinesList: string[] = [];

	const hasBash = tools.includes("bash");
	const hasEdit = tools.includes("edit");
	const hasWrite = tools.includes("write");
	const hasGrep = tools.includes("grep");
	const hasFind = tools.includes("find");
	const hasLs = tools.includes("ls");
	const hasRead = tools.includes("read");

	// Read-only mode notice (no bash, edit, or write)
	if (!hasBash && !hasEdit && !hasWrite) {
		guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands");
	}

	// Bash without edit/write = read-only bash mode
	if (hasBash && !hasEdit && !hasWrite) {
		guidelinesList.push(
			"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
		);
	}

	// File exploration guidelines
	if (hasBash && !hasGrep && !hasFind && !hasLs) {
		guidelinesList.push("Use bash for file operations like ls, grep, find");
	} else if (hasBash && (hasGrep || hasFind || hasLs)) {
		guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
	}

	// Read before edit guideline
	if (hasRead && hasEdit) {
		guidelinesList.push("Use read to examine files before editing");
	}

	// Edit guideline
	if (hasEdit) {
		guidelinesList.push("Use edit for precise changes (old text must match exactly)");
	}

	// Write guideline
	if (hasWrite) {
		guidelinesList.push("Use write only for new files or complete rewrites");
	}

	// Output guideline (only when actually writing/executing)
	if (hasEdit || hasWrite) {
		guidelinesList.push(
			"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
		);
	}

	// Always include these
	guidelinesList.push("Be concise in your responses");
	guidelinesList.push("Show file paths clearly when working with files");

	const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");

	let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.

Available tools:
${toolsList}

Guidelines:
${guidelines}

Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Additional documentation (hooks, themes, RPC, etc.) is in: ${docsPath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;

	if (appendSection) {
		prompt += appendSection;
	}

	// Append project context files
	const contextFiles = loadProjectContextFiles();
	if (contextFiles.length > 0) {
		prompt += "\n\n# Project Context\n\n";
		prompt += "The following project context files have been loaded:\n\n";
		for (const { path: filePath, content } of contextFiles) {
			prompt += `## ${filePath}\n\n${content}\n\n`;
		}
	}

	// Append skills section (only if read tool is available)
	if (skillsEnabled && hasRead) {
		const skills = loadSkills();
		prompt += formatSkillsForPrompt(skills);
	}

	// Add date/time and working directory last
	prompt += `\nCurrent date and time: ${dateTime}`;
	prompt += `\nCurrent working directory: ${process.cwd()}`;

	return prompt;
}


Now I'll update the Documentation section to be more actionable:

edit

Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Additional documentation (hooks, themes, RPC, etc.) is in: ${docsPath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;

Documentation:
- Your own documentation is at: ${readmePath}
- Additional documentation is in: ${docsPath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/system-prompt.ts. Changed 362 characters to 285 characters.

     ...
 234 Guidelines:
 235 ${guidelines}
 236
 237 Documentation:
-238 - Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
-239 - Additional documentation (hooks, themes, RPC, etc.) is in: ${docsPath}
+238 - Your own documentation is at: ${readmePath}
+239 - Additional documentation is in: ${docsPath}
 240 - Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;
 241
 242 	if (appendSection) {
 243 		prompt += appendSection;
     ...


Wait, that's actually less helpful. Let me make it more specific with the doc file mappings:

Documentation:
- Your own documentation is at: ${readmePath}
- Additional documentation is in: ${docsPath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;

Documentation:
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/system-prompt.ts. Changed 285 characters to 275 characters.

     ...
 234 Guidelines:
 235 ${guidelines}
 236
 237 Documentation:
-238 - Your own documentation is at: ${readmePath}
-239 - Additional documentation is in: ${docsPath}
-240 - Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;
+238 - Main documentation: ${readmePath}
+239 - Additional docs: ${docsPath}
+240 - When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;
 241
 242 	if (appendSection) {
 243 		prompt += appendSection;
 244 	}
     ...


Now let's check the docs files to make sure they're up to date. Let me read them:


# Pi Coding Agent Themes

Themes allow you to customize the colors used throughout the coding agent TUI.

## Color Tokens

Every theme must define all color tokens. There are no optional colors.

### Core UI (10 colors)

| Token | Purpose | Examples |
|-------|---------|----------|
| `accent` | Primary accent color | Logo, selected items, cursor (›) |
| `border` | Normal borders | Selector borders, horizontal lines |
| `borderAccent` | Highlighted borders | Changelog borders, special panels |
| `borderMuted` | Subtle borders | Editor borders, secondary separators |
| `success` | Success states | Success messages, diff additions |
| `error` | Error states | Error messages, diff deletions |
| `warning` | Warning states | Warning messages |
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
| `dim` | Very dimmed text | Less important info, placeholders |
| `text` | Default text color | Main content (usually `""`) |

### Backgrounds & Content Text (7 colors)

| Token | Purpose |
|-------|---------|
| `userMessageBg` | User message background |
| `userMessageText` | User message text color |
| `toolPendingBg` | Tool execution box (pending state) |
| `toolSuccessBg` | Tool execution box (success state) |
| `toolErrorBg` | Tool execution box (error state) |
| `toolTitle` | Tool execution title/heading (e.g., `$ command`, `read file.txt`) |
| `toolOutput` | Tool execution output text |

### Markdown (10 colors)

| Token | Purpose |
|-------|---------|
| `mdHeading` | Heading text (`#`, `##`, etc) |
| `mdLink` | Link text |
| `mdLinkUrl` | Link URL (in parentheses) |
| `mdCode` | Inline code (backticks) |
| `mdCodeBlock` | Code block content |
| `mdCodeBlockBorder` | Code block fences (```) |
| `mdQuote` | Blockquote text |
| `mdQuoteBorder` | Blockquote border (`│`) |
| `mdHr` | Horizontal rule (`---`) |
| `mdListBullet` | List bullets/numbers |

### Tool Diffs (3 colors)

| Token | Purpose |
|-------|---------|
| `toolDiffAdded` | Added lines in tool diffs |
| `toolDiffRemoved` | Removed lines in tool diffs |
| `toolDiffContext` | Context lines in tool diffs |

Note: Diff colors are specific to tool execution boxes and must work with tool background colors.

### Syntax Highlighting (9 colors)

Future-proofing for syntax highlighting support:

| Token | Purpose |
|-------|---------|
| `syntaxComment` | Comments |
| `syntaxKeyword` | Keywords (`if`, `function`, etc) |
| `syntaxFunction` | Function names |
| `syntaxVariable` | Variable names |
| `syntaxString` | String literals |
| `syntaxNumber` | Number literals |
| `syntaxType` | Type names |
| `syntaxOperator` | Operators (`+`, `-`, etc) |
| `syntaxPunctuation` | Punctuation (`;`, `,`, etc) |

### Thinking Level Borders (5 colors)

Editor border colors that indicate the current thinking/reasoning level:

| Token | Purpose |
|-------|---------|
| `thinkingOff` | Border when thinking is off (most subtle) |
| `thinkingMinimal` | Border for minimal thinking |
| `thinkingLow` | Border for low thinking |
| `thinkingMedium` | Border for medium thinking |
| `thinkingHigh` | Border for high thinking (most prominent) |

These create a visual hierarchy: off → minimal → low → medium → high

**Total: 44 color tokens** (all required)

## Theme Format

Themes are defined in JSON files with the following structure:

```json
{
  "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
  "name": "my-theme",
  "vars": {
    "blue": "#0066cc",
    "gray": 242,
    "brightCyan": 51
  },
  "colors": {
    "accent": "blue",
    "muted": "gray",
    "text": "",
    ...
  }
}
```

### Color Values

Four formats are supported:

1. **Hex colors**: `"#ff0000"` (6-digit hex RGB)
2. **256-color palette**: `39` (number 0-255, xterm 256-color palette)
3. **Color references**: `"blue"` (must be defined in `vars`)
4. **Terminal default**: `""` (empty string, uses terminal's default color)

### The `vars` Section

The optional `vars` section allows you to define reusable colors:

```json
{
  "vars": {
    "nord0": "#2E3440",
    "nord1": "#3B4252",
    "nord8": "#88C0D0",
    "brightBlue": 39
  },
  "colors": {
    "accent": "nord8",
    "muted": "nord1",
    "mdLink": "brightBlue"
  }
}
```

Benefits:
- Reuse colors across multiple tokens
- Easier to maintain theme consistency
- Can reference standard color palettes

Variables can be hex colors (`"#ff0000"`), 256-color indices (`42`), or references to other variables.

### Terminal Default (empty string)

Use `""` (empty string) to inherit the terminal's default foreground/background color:

```json
{
  "colors": {
    "text": ""  // Uses terminal's default text color
  }
}
```

This is useful for:
- Main text color (adapts to user's terminal theme)
- Creating themes that blend with terminal appearance

## Built-in Themes

Pi comes with two built-in themes:

### `dark` (default)

Optimized for dark terminal backgrounds with bright, saturated colors.

### `light`

Optimized for light terminal backgrounds with darker, muted colors.

## Selecting a Theme

Themes are configured in the settings (accessible via `/settings`):

```json
{
  "theme": "dark"
}
```

Or use the `/theme` command interactively.

On first run, Pi detects your terminal's background and sets a sensible default (`dark` or `light`).

## Custom Themes

### Theme Locations

Custom themes are loaded from `~/.pi/agent/themes/*.json`.

### Creating a Custom Theme

1. **Create theme directory:**
   ```bash
   mkdir -p ~/.pi/agent/themes
   ```

2. **Create theme file:**
   ```bash
   vim ~/.pi/agent/themes/my-theme.json
   ```

3. **Define all colors:**
   ```json
   {
     "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
     "name": "my-theme",
     "vars": {
       "primary": "#00aaff",
       "secondary": 242,
       "brightGreen": 46
     },
     "colors": {
       "accent": "primary",
       "border": "primary",
       "borderAccent": "#00ffff",
       "borderMuted": "secondary",
       "success": "brightGreen",
       "error": "#ff0000",
       "warning": "#ffff00",
       "muted": "secondary",
       "text": "",

       "userMessageBg": "#2d2d30",
       "userMessageText": "",
       "toolPendingBg": "#1e1e2e",
       "toolSuccessBg": "#1e2e1e",
       "toolErrorBg": "#2e1e1e",
       "toolText": "",

       "mdHeading": "#ffaa00",
       "mdLink": "primary",
       "mdCode": "#00ffff",
       "mdCodeBlock": "#00ff00",
       "mdCodeBlockBorder": "secondary",
       "mdQuote": "secondary",
       "mdQuoteBorder": "secondary",
       "mdHr": "secondary",
       "mdListBullet": "#00ffff",

       "toolDiffAdded": "#00ff00",
       "toolDiffRemoved": "#ff0000",
       "toolDiffContext": "secondary",

       "syntaxComment": "secondary",
       "syntaxKeyword": "primary",
       "syntaxFunction": "#00aaff",
       "syntaxVariable": "#ffaa00",
       "syntaxString": "#00ff00",
       "syntaxNumber": "#ff00ff",
       "syntaxType": "#00aaff",
       "syntaxOperator": "primary",
       "syntaxPunctuation": "secondary",

       "thinkingOff": "secondary",
       "thinkingMinimal": "primary",
       "thinkingLow": "#00aaff",
       "thinkingMedium": "#00ffff",
       "thinkingHigh": "#ff00ff"
     }
   }
   ```

4. **Select your theme:**
   - Use `/settings` command and set `"theme": "my-theme"`
   - Or use `/theme` command interactively

## Tips

### Light vs Dark Themes

**For dark terminals:**
- Use bright, saturated colors
- Higher contrast
- Example: `#00ffff` (bright cyan)

**For light terminals:**
- Use darker, muted colors
- Lower contrast to avoid eye strain
- Example: `#008888` (dark cyan)

### Color Harmony

- Start with a base palette (e.g., Nord, Gruvbox, Tokyo Night)
- Define your palette in `defs`
- Reference colors consistently

### Testing

Test your theme with:
- Different message types (user, assistant, errors)
- Tool executions (success and error states)
- Markdown content (headings, code, lists, etc)
- Long text that wraps

## Color Format Reference

### Hex Colors

Standard 6-digit hex format:
- `"#ff0000"` - Red
- `"#00ff00"` - Green
- `"#0000ff"` - Blue
- `"#808080"` - Gray
- `"#ffffff"` - White
- `"#000000"` - Black

RGB values: `#RRGGBB` where each component is `00-ff` (0-255)

### 256-Color Palette

Use numeric indices (0-255) to reference the xterm 256-color palette:

**Colors 0-15:** Basic ANSI colors (terminal-dependent, may be themed)
- `0` - Black
- `1` - Red
- `2` - Green
- `3` - Yellow
- `4` - Blue
- `5` - Magenta
- `6` - Cyan
- `7` - White
- `8-15` - Bright variants

**Colors 16-231:** 6×6×6 RGB cube (standardized)
- Formula: `16 + 36×R + 6×G + B` where R, G, B are 0-5
- Example: `39` = bright cyan, `196` = bright red

**Colors 232-255:** Grayscale ramp (standardized)
- `232` - Darkest gray
- `255` - Near white

Example usage:
```json
{
  "vars": {
    "gray": 242,
    "brightCyan": 51,
    "darkBlue": 18
  },
  "colors": {
    "muted": "gray",
    "accent": "brightCyan"
  }
}
```

**Benefits:**
- Works everywhere (`TERM=xterm-256color`)
- No truecolor detection needed
- Standardized RGB cube (16-231) looks the same on all terminals

### Terminal Compatibility

Pi uses 24-bit RGB colors (`\x1b[38;2;R;G;Bm`). Most modern terminals support this:

- ✅ iTerm2, Alacritty, Kitty, WezTerm
- ✅ Windows Terminal
- ✅ VS Code integrated terminal
- ✅ Modern GNOME Terminal, Konsole

For older terminals with only 256-color support, Pi automatically falls back to the nearest 256-color approximation.

To check if your terminal supports truecolor:
```bash
echo $COLORTERM  # Should output "truecolor" or "24bit"
```

## Example Themes

See the built-in themes for complete examples:
- [Dark theme](../src/themes/dark.json)
- [Light theme](../src/themes/light.json)

## Schema Validation

Themes are validated on load using [TypeBox](https://github.com/sinclairzx81/typebox) + [Ajv](https://ajv.js.org/).

Invalid themes will show an error with details about what's wrong:
```
Error loading theme 'my-theme':
  - colors.accent: must be string or number
  - colors.mdHeading: required property missing
```

For editor support, the JSON schema is available at:
```
https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json
```

Add to your theme file for auto-completion and validation:
```json
{
  "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
  ...
}
```

## Implementation

### Theme Class

Themes are loaded and converted to a `Theme` class that provides type-safe color methods:

```typescript
class Theme {
  // Apply foreground color
  fg(color: ThemeColor, text: string): string

  // Apply background color
  bg(color: ThemeBg, text: string): string

  // Text attributes (preserve current colors)
  bold(text: string): string
  dim(text: string): string
  italic(text: string): string
}
```

### Global Theme Instance

The active theme is available as a global singleton in `coding-agent`:

```typescript
// theme.ts
export let theme: Theme;

export function setTheme(name: string) {
  theme = loadTheme(name);
}

// Usage throughout coding-agent
import { theme } from './theme.js';

theme.fg('accent', 'Selected')
theme.bg('userMessageBg', content)
```

### TUI Component Theming

TUI components (like `Markdown`, `SelectList`, `Editor`) are in the `@mariozechner/pi-tui` package and don't have direct access to the theme. Instead, they define interfaces for the colors they need:

```typescript
// In @mariozechner/pi-tui
export interface MarkdownTheme {
  heading: (text: string) => string;
  link: (text: string) => string;
  code: (text: string) => string;
  codeBlock: (text: string) => string;
  codeBlockBorder: (text: string) => string;
  quote: (text: string) => string;
  quoteBorder: (text: string) => string;
  hr: (text: string) => string;
  listBullet: (text: string) => string;
}

export class Markdown {
  constructor(
    text: string,
    paddingX: number,
    paddingY: number,
    defaultTextStyle?: DefaultTextStyle,
    theme?: MarkdownTheme  // Optional theme functions
  )

  // Usage in component
  renderHeading(text: string) {
    return this.theme.heading(text);  // Applies color
  }
}
```

The `coding-agent` provides themed functions when creating components:

```typescript
// In coding-agent
import { theme } from './theme.js';
import { Markdown } from '@mariozechner/pi-tui';

// Helper to create markdown theme functions
function getMarkdownTheme(): MarkdownTheme {
  return {
    heading: (text) => theme.fg('mdHeading', text),
    link: (text) => theme.fg('mdLink', text),
    code: (text) => theme.fg('mdCode', text),
    codeBlock: (text) => theme.fg('mdCodeBlock', text),
    codeBlockBorder: (text) => theme.fg('mdCodeBlockBorder', text),
    quote: (text) => theme.fg('mdQuote', text),
    quoteBorder: (text) => theme.fg('mdQuoteBorder', text),
    hr: (text) => theme.fg('mdHr', text),
    listBullet: (text) => theme.fg('mdListBullet', text),
  };
}

// Create markdown with theme
const md = new Markdown(
  text,
  1, 1,
  { bgColor: theme.bg('userMessageBg') },
  getMarkdownTheme()
);
```

This approach:
- Keeps TUI components theme-agnostic (reusable in other projects)
- Maintains type safety via interfaces
- Allows components to have sensible defaults if no theme provided
- Centralizes theme access in `coding-agent`

**Example usage:**
```typescript
const theme = loadTheme('dark');

// Apply foreground colors
theme.fg('accent', 'Selected')
theme.fg('success', '✓ Done')
theme.fg('error', 'Failed')

// Apply background colors
theme.bg('userMessageBg', content)
theme.bg('toolSuccessBg', output)

// Combine styles
theme.bold(theme.fg('accent', 'Title'))
theme.dim(theme.fg('muted', 'metadata'))

// Nested foreground + background
const userMsg = theme.bg('userMessageBg',
  theme.fg('userMessageText', 'Hello')
)
```

**Color resolution:**

1. **Detect terminal capabilities:**
   - Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support)
   - Check `$TERM` env var (`*-256color` → 256-color support)
   - Fallback to 256-color mode if detection fails

2. **Load JSON theme file**

3. **Resolve `vars` references recursively:**
   ```json
   {
     "vars": {
       "primary": "#0066cc",
       "accent": "primary"
     },
     "colors": {
       "accent": "accent"  // → "primary" → "#0066cc"
     }
   }
   ```

4. **Convert colors to ANSI codes based on terminal capability:**

   **Truecolor mode (24-bit):**
   - Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m`
   - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
   - Empty string (`""`) → `\x1b[39m`

   **256-color mode:**
   - Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m`
   - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
   - Empty string (`""`) → `\x1b[39m`

   **Hex to 256-color conversion:**
   ```typescript
   // Convert RGB to 6x6x6 cube (colors 16-231)
   r_index = Math.round(r / 255 * 5)
   g_index = Math.round(g / 255 * 5)
   b_index = Math.round(b / 255 * 5)
   color_index = 16 + 36 * r_index + 6 * g_index + b_index
   ```

5. **Cache as `Theme` instance**

This ensures themes work correctly regardless of terminal capabilities, with graceful degradation from truecolor to 256-color.


# Skills

Skills are instruction files that the agent loads on-demand for specific tasks.

## Skill Locations

Skills are discovered from these locations (in order of priority, later wins on name collision):

1. `~/.codex/skills/**/SKILL.md` (Codex CLI user skills, recursive)
2. `~/.claude/skills/*/SKILL.md` (Claude Code user skills)
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project skills)
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user skills, recursive)
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project skills, recursive)

Skill names and descriptions are listed in the system prompt. When a task matches a skill's description, the agent uses the `read` tool to load it.

## Creating Skills

A skill is a markdown file with YAML frontmatter containing a `description` field:

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

# PDF Processing Instructions

1. Use `pdftotext` to extract plain text
2. For tables, use `tabula-py` or similar
3. Always verify extraction quality

Scripts are in: {baseDir}/scripts/
```

### Frontmatter Fields

| Field | Required | Description |
|-------|----------|-------------|
| `description` | Yes | Short description for skill selection |
| `name` | No | Override skill name (defaults to filename or directory name) |

The parser only supports single-line `key: value` syntax. Multiline YAML blocks are not supported.

### Variables

Use `{baseDir}` as a placeholder for the skill's directory. The agent is told each skill's base directory and will substitute it when following the instructions.

### Subdirectories

Pi and Codex skills in subdirectories use colon-separated names:
- `~/.pi/agent/skills/db/migrate/SKILL.md` → `db:migrate`
- `<cwd>/.pi/skills/aws/s3/upload/SKILL.md` → `aws:s3:upload`

## Claude Code Compatibility

Pi reads Claude Code skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored since Pi cannot enforce them.

## Codex CLI Compatibility

Pi reads Codex CLI skills from `~/.codex/skills/`. Unlike Claude Code skills (one level deep), Codex skills are scanned recursively, matching Codex CLI's behavior. Hidden files/directories (starting with `.`) and symlinks are skipped.

## Disabling Skills

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

Or in `~/.pi/agent/settings.json`:
```json
{
  "skills": {
    "enabled": false
  }
}
```

## Example

```markdown
---
description: Perform code review with security and performance analysis
---

# Code Review

Analyze:

## Security
- Input validation
- SQL injection
- XSS vulnerabilities

## Performance
- Algorithm complexity
- Memory usage
- Query efficiency
```


# Hooks

Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more.

## Hook Locations

Hooks are automatically discovered from two locations:

1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
2. **Project hooks**: `<cwd>/.pi/hooks/*.ts`

All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).

You can also load a specific hook file directly using the `--hook` flag:

```bash
pi --hook ./my-hook.ts
```

This is useful for testing hooks without placing them in the standard directories.

### Additional Configuration

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

```json
{
  "hooks": [
    "/path/to/custom/hook.ts"
  ],
  "hookTimeout": 30000
}
```

- `hooks`: Additional hook file paths (supports `~` expansion)
- `hookTimeout`: Timeout in milliseconds for non-interactive hook operations (default: 30000)

## Writing a Hook

A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.

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

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

### Setup

Create a hooks directory:

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

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

Then create `.ts` files directly in these directories. Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically.

## Events

### Lifecycle

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

user branches (/branch)
  │
  ├─► branch (BEFORE branch, can control)
  └─► session (reason: "switch", AFTER branch)

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

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

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

### session

Fired on startup and when session changes.

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

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

### branch

Fired BEFORE a branch happens. Can control branch behavior.

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

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

### agent_start / agent_end

Fired once per user prompt.

```typescript
pi.on("agent_start", async (event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages: AppMessage[] - new messages from this prompt
});
```

### turn_start / turn_end

Fired for each turn within an agent loop.

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

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

### tool_call

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

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

Tool inputs:
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
- `edit`: `{ path, oldText, newText }`
- `ls`: `{ path?, limit? }`
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`

### tool_result

Fired after tool executes. **Can modify result.**

```typescript
pi.on("tool_result", async (event, ctx) => {
  // event.toolName, event.toolCallId, event.input
  // event.result: string
  // event.isError: boolean
  return { result: "modified" }; // or undefined to keep original
});
```

## Context API

Every event handler receives a context object with these methods:

### ctx.ui.select(title, options)

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

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

### ctx.ui.confirm(title, message)

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

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

### ctx.ui.input(title, placeholder?)

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

```typescript
const name = await ctx.ui.input("Enter name:", "default value");
```

### ctx.ui.notify(message, type?)

Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.

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

### ctx.exec(command, args)

Execute a command and get the result.

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

### ctx.cwd

The current working directory.

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

### ctx.sessionFile

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

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

### ctx.hasUI

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

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

## Sending Messages

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

- Waking up the agent when an external event occurs (file change, CI result, etc.)
- Async debugging (inject debug output from other processes)
- Triggering agent actions from external systems

```typescript
pi.send(text: string, attachments?: Attachment[]): void
```

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

### Example: File Watcher

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

export default function (pi: HookAPI) {
  pi.on("session", async (event, ctx) => {
    if (event.reason !== "start") return;

    // Watch a trigger file
    const triggerFile = "/tmp/agent-trigger.txt";

    fs.watch(triggerFile, () => {
      try {
        const content = fs.readFileSync(triggerFile, "utf-8").trim();
        if (content) {
          pi.send(`External trigger: ${content}`);
          fs.writeFileSync(triggerFile, ""); // Clear after reading
        }
      } catch {
        // File might not exist yet
      }
    });

    ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
  });
}
```

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

### Example: HTTP Webhook

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

export default function (pi: HookAPI) {
  pi.on("session", async (event, ctx) => {
    if (event.reason !== "start") return;

    const server = http.createServer((req, res) => {
      let body = "";
      req.on("data", chunk => body += chunk);
      req.on("end", () => {
        pi.send(body || "Webhook triggered");
        res.writeHead(200);
        res.end("OK");
      });
    });

    server.listen(3333, () => {
      ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
    });
  });
}
```

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

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

## Examples

### Shitty Permission Gate

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

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

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

    const command = event.input.command as string;
    const isDangerous = dangerousPatterns.some((p) => p.test(command));

    if (isDangerous) {
      const choice = await ctx.ui.select(
        `⚠ Dangerous command:\n\n  ${command}\n\nAllow?`,
        ["Yes", "No"]
      );

      if (choice !== "Yes") {
        return { block: true, reason: "Blocked by user" };
      }
    }

    return undefined;
  });
}
```

### Git Checkpointing

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

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

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

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

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

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

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

    return undefined;
  });

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

### Block Writes to Certain Paths

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

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

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

    const path = event.input.path as string;
    const isProtected = protectedPaths.some((p) => path.includes(p));

    if (isProtected) {
      ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
      return { block: true, reason: `Path "${path}" is protected` };
    }

    return undefined;
  });
}
```

## Mode Behavior

Hooks behave differently depending on the run mode:

| Mode | UI Methods | Notes |
|------|-----------|-------|
| Interactive | Full TUI dialogs | User can interact normally |
| RPC | JSON protocol | Host application handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |

In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.

## Error Handling

- If a hook throws an error, it's logged and the agent continues
- If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)
- Hook errors are displayed in the UI with the hook path and error message

## Debugging

To debug a hook:

1. Open VS Code in your hooks directory
2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
3. Set breakpoints in your hook file
4. Run `pi --hook ./my-hook.ts` in the debug terminal

The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.

---

# Internals

## Discovery and Loading

Hooks are discovered and loaded at startup in `main.ts`:

```
main.ts
  -> discoverAndLoadHooks(configuredPaths, cwd)  [loader.ts]
     -> discoverHooksInDir(~/.pi/agent/hooks/)   # global hooks
     -> discoverHooksInDir(cwd/.pi/hooks/)       # project hooks
     -> merge with configuredPaths (deduplicated)
     -> for each path:
        -> jiti.import(path)                     # TypeScript support via jiti
        -> hookFactory(hookAPI)                  # calls pi.on() to register handlers
        -> returns LoadedHook { path, handlers: Map<eventType, handlers[]> }
```

## Tool Wrapping

Tools are wrapped with hook callbacks before the agent is created:

```
main.ts
  -> wrapToolsWithHooks(tools, hookRunner)  [tool-wrapper.ts]
     -> returns new tools with wrapped execute() functions
```

The wrapped `execute()` function:

1. Checks `hookRunner.hasHandlers("tool_call")`
2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
3. If result has `block: true`, throws an error
4. Otherwise, calls the original `tool.execute()`
5. Checks `hookRunner.hasHandlers("tool_result")`
6. If yes, calls `hookRunner.emit(event)` (with timeout)
7. Returns (possibly modified) result

## HookRunner

The `HookRunner` class manages hook execution:

```typescript
class HookRunner {
  constructor(hooks: LoadedHook[], cwd: string, timeout?: number)

  setUIContext(ctx: HookUIContext, hasUI: boolean): void
  setSessionFile(path: string | null): void
  onError(listener): () => void
  hasHandlers(eventType: string): boolean
  emit(event: HookEvent): Promise<Result>
  emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
}
```

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

## Event Flow

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

User sends prompt:
  -> AgentSession.prompt()
     -> hookRunner.emit({ type: "agent_start" })
     -> hookRunner.emit({ type: "turn_start", turnIndex })
     -> agent loop:
        -> LLM generates tool calls
        -> For each tool call:
           -> wrappedTool.execute()
              -> hookRunner.emitToolCall({ type: "tool_call", ... })
              -> [if not blocked] originalTool.execute()
              -> hookRunner.emit({ type: "tool_result", ... })
        -> LLM generates response
     -> hookRunner.emit({ type: "turn_end", ... })
     -> [repeat if more tool calls]
  -> hookRunner.emit({ type: "agent_end", messages })

Branch:
  -> AgentSession.branch()
     -> hookRunner.emit({ type: "branch", ... })  # BEFORE branch
     -> [branch happens]
     -> hookRunner.emit({ type: "session", reason: "switch", ... })  # AFTER

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

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

## UI Context by Mode

Each mode provides its own `HookUIContext` implementation:

**Interactive Mode** (`interactive-mode.ts`):
- `select()` -> `HookSelectorComponent` (TUI list selector)
- `confirm()` -> `HookSelectorComponent` with Yes/No options
- `input()` -> `HookInputComponent` (TUI text input)
- `notify()` -> Adds text to chat container

**RPC Mode** (`rpc-mode.ts`):
- All methods send JSON requests via stdout
- Waits for JSON responses via stdin
- Host application renders UI and sends responses

**Print Mode** (`print-mode.ts`):
- All methods return null/false immediately
- `notify()` is a no-op

## File Structure

```
packages/coding-agent/src/core/hooks/
├── index.ts          # Public exports
├── types.ts          # Event types, HookAPI, contexts
├── loader.ts         # jiti-based hook loading
├── runner.ts         # HookRunner class
└── tool-wrapper.ts   # Tool wrapping for interception
```