# RPC Mode

RPC mode enables headless operation of the coding agent via a JSON protocol over stdin/stdout. This is useful for embedding the agent in other applications, IDEs, or custom UIs.

**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@mariozechner/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).

## Starting RPC Mode

```bash
pi --mode rpc [options]
```

Common options:
- `--provider <name>`: Set the LLM provider (anthropic, openai, google, etc.)
- `--model <id>`: Set the model ID
- `--no-session`: Disable session persistence
- `--session-dir <path>`: Custom session storage directory

## Protocol Overview

- **Commands**: JSON objects sent to stdin, one per line
- **Responses**: JSON objects with `type: "response"` indicating command success/failure
- **Events**: Agent events streamed to stdout as JSON lines

All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`.

## Commands

### Prompting

#### prompt

Send a user prompt to the agent. Returns immediately; events stream asynchronously.

```json
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
```

With attachments:
```json
{"type": "prompt", "message": "What's in this image?", "attachments": [...]}
```

Response:
```json
{"id": "req-1", "type": "response", "command": "prompt", "success": true}
```

The `attachments` field is optional. See [Attachments](#attachments) for the schema.

#### queue_message

Queue a message to be injected at the next agent turn. Queued messages are added to the conversation without triggering a new prompt. Useful for injecting context mid-conversation.

```json
{"type": "queue_message", "message": "Additional context"}
```

Response:
```json
{"type": "response", "command": "queue_message", "success": true}
```

See [set_queue_mode](#set_queue_mode) for controlling how queued messages are processed.

#### abort

Abort the current agent operation.

```json
{"type": "abort"}
```

Response:
```json
{"type": "response", "command": "abort", "success": true}
```

#### reset

Clear context and start a fresh session.

```json
{"type": "reset"}
```

Response:
```json
{"type": "response", "command": "reset", "success": true}
```

### State

#### get_state

Get current session state.

```json
{"type": "get_state"}
```

Response:
```json
{
  "type": "response",
  "command": "get_state",
  "success": true,
  "data": {
    "model": {...},
    "thinkingLevel": "medium",
    "isStreaming": false,
    "isCompacting": false,
    "queueMode": "all",
    "sessionFile": "/path/to/session.jsonl",
    "sessionId": "abc123",
    "autoCompactionEnabled": true,
    "messageCount": 5,
    "queuedMessageCount": 0
  }
}
```

The `model` field is a full [Model](#model) object or `null`.

#### get_messages

Get all messages in the conversation.

```json
{"type": "get_messages"}
```

Response:
```json
{
  "type": "response",
  "command": "get_messages",
  "success": true,
  "data": {"messages": [...]}
}
```

Messages are `AppMessage` objects (see [Message Types](#message-types)).

### Model

#### set_model

Switch to a specific model.

```json
{"type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4-20250514"}
```

Response contains the full [Model](#model) object:
```json
{
  "type": "response",
  "command": "set_model",
  "success": true,
  "data": {...}
}
```

#### cycle_model

Cycle to the next available model. Returns `null` data if only one model available.

```json
{"type": "cycle_model"}
```

Response:
```json
{
  "type": "response",
  "command": "cycle_model",
  "success": true,
  "data": {
    "model": {...},
    "thinkingLevel": "medium",
    "isScoped": false
  }
}
```

The `model` field is a full [Model](#model) object.

#### get_available_models

List all configured models.

```json
{"type": "get_available_models"}
```

Response contains an array of full [Model](#model) objects:
```json
{
  "type": "response",
  "command": "get_available_models",
  "success": true,
  "data": {
    "models": [...]
  }
}
```

### Thinking

#### set_thinking_level

Set the reasoning/thinking level for models that support it.

```json
{"type": "set_thinking_level", "level": "high"}
```

Levels: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`

Note: `"xhigh"` is only supported by OpenAI codex-max models.

Response:
```json
{"type": "response", "command": "set_thinking_level", "success": true}
```

#### cycle_thinking_level

Cycle through available thinking levels. Returns `null` data if model doesn't support thinking.

```json
{"type": "cycle_thinking_level"}
```

Response:
```json
{
  "type": "response",
  "command": "cycle_thinking_level",
  "success": true,
  "data": {"level": "high"}
}
```

### Queue Mode

#### set_queue_mode

Control how queued messages (from `queue_message`) are injected into the conversation.

```json
{"type": "set_queue_mode", "mode": "one-at-a-time"}
```

Modes:
- `"all"`: Inject all queued messages at the next turn
- `"one-at-a-time"`: Inject one queued message per turn (default)

Response:
```json
{"type": "response", "command": "set_queue_mode", "success": true}
```

### Compaction

#### compact

Manually compact conversation context to reduce token usage.

```json
{"type": "compact"}
```

With custom instructions:
```json
{"type": "compact", "customInstructions": "Focus on code changes"}
```

Response:
```json
{
  "type": "response",
  "command": "compact",
  "success": true,
  "data": {
    "tokensBefore": 150000,
    "summary": "Summary of conversation..."
  }
}
```

#### set_auto_compaction

Enable or disable automatic compaction when context is nearly full.

```json
{"type": "set_auto_compaction", "enabled": true}
```

Response:
```json
{"type": "response", "command": "set_auto_compaction", "success": true}
```

### Retry

#### set_auto_retry

Enable or disable automatic retry on transient errors (overloaded, rate limit, 5xx).

```json
{"type": "set_auto_retry", "enabled": true}
```

Response:
```json
{"type": "response", "command": "set_auto_retry", "success": true}
```

#### abort_retry

Abort an in-progress retry (cancel the delay and stop retrying).

```json
{"type": "abort_retry"}
```

Response:
```json
{"type": "response", "command": "abort_retry", "success": true}
```

### Bash

#### bash

Execute a shell command and add output to conversation context.

```json
{"type": "bash", "command": "ls -la"}
```

Response:
```json
{
  "type": "response",
  "command": "bash",
  "success": true,
  "data": {
    "output": "total 48\ndrwxr-xr-x ...",
    "exitCode": 0,
    "cancelled": false,
    "truncated": false
  }
}
```

If output was truncated, includes `fullOutputPath`:
```json
{
  "type": "response",
  "command": "bash",
  "success": true,
  "data": {
    "output": "truncated output...",
    "exitCode": 0,
    "cancelled": false,
    "truncated": true,
    "fullOutputPath": "/tmp/pi-bash-abc123.log"
  }
}
```

**How bash results reach the LLM:**

The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state. This message does NOT emit an event.

When the next `prompt` command is sent, all messages (including `BashExecutionMessage`) are transformed before being sent to the LLM. The `BashExecutionMessage` is converted to a `UserMessage` with this format:

```
Ran `ls -la`
\`\`\`
total 48
drwxr-xr-x ...
\`\`\`
```

This means:
1. Bash output is included in the LLM context on the **next prompt**, not immediately
2. Multiple bash commands can be executed before a prompt; all outputs will be included
3. No event is emitted for the `BashExecutionMessage` itself

#### abort_bash

Abort a running bash command.

```json
{"type": "abort_bash"}
```

Response:
```json
{"type": "response", "command": "abort_bash", "success": true}
```

### Session

#### get_session_stats

Get token usage and cost statistics.

```json
{"type": "get_session_stats"}
```

Response:
```json
{
  "type": "response",
  "command": "get_session_stats",
  "success": true,
  "data": {
    "sessionFile": "/path/to/session.jsonl",
    "sessionId": "abc123",
    "userMessages": 5,
    "assistantMessages": 5,
    "toolCalls": 12,
    "toolResults": 12,
    "totalMessages": 22,
    "tokens": {
      "input": 50000,
      "output": 10000,
      "cacheRead": 40000,
      "cacheWrite": 5000,
      "total": 105000
    },
    "cost": 0.45
  }
}
```

#### export_html

Export session to an HTML file.

```json
{"type": "export_html"}
```

With custom path:
```json
{"type": "export_html", "outputPath": "/tmp/session.html"}
```

Response:
```json
{
  "type": "response",
  "command": "export_html",
  "success": true,
  "data": {"path": "/tmp/session.html"}
}
```

#### switch_session

Load a different session file.

```json
{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
```

Response:
```json
{"type": "response", "command": "switch_session", "success": true}
```

#### branch

Create a new branch from a previous user message. Returns the text of the message being branched from.

```json
{"type": "branch", "entryIndex": 2}
```

Response:
```json
{
  "type": "response",
  "command": "branch",
  "success": true,
  "data": {"text": "The original prompt text..."}
}
```

#### get_branch_messages

Get user messages available for branching.

```json
{"type": "get_branch_messages"}
```

Response:
```json
{
  "type": "response",
  "command": "get_branch_messages",
  "success": true,
  "data": {
    "messages": [
      {"entryIndex": 0, "text": "First prompt..."},
      {"entryIndex": 2, "text": "Second prompt..."}
    ]
  }
}
```

#### get_last_assistant_text

Get the text content of the last assistant message.

```json
{"type": "get_last_assistant_text"}
```

Response:
```json
{
  "type": "response",
  "command": "get_last_assistant_text",
  "success": true,
  "data": {"text": "The assistant's response..."}
}
```

Returns `{"text": null}` if no assistant messages exist.

## Events

Events are streamed to stdout as JSON lines during agent operation. Events do NOT include an `id` field (only responses do).

### Event Types

| Event | Description |
|-------|-------------|
| `agent_start` | Agent begins processing |
| `agent_end` | Agent completes (includes all generated messages) |
| `turn_start` | New turn begins |
| `turn_end` | Turn completes (includes assistant message and tool results) |
| `message_start` | Message begins |
| `message_update` | Streaming update (text/thinking/toolcall deltas) |
| `message_end` | Message completes |
| `tool_execution_start` | Tool begins execution |
| `tool_execution_update` | Tool execution progress (streaming output) |
| `tool_execution_end` | Tool completes |
| `auto_compaction_start` | Auto-compaction begins |
| `auto_compaction_end` | Auto-compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) |

### agent_start

Emitted when the agent begins processing a prompt.

```json
{"type": "agent_start"}
```

### agent_end

Emitted when the agent completes. Contains all messages generated during this run.

```json
{
  "type": "agent_end",
  "messages": [...]
}
```

### turn_start / turn_end

A turn consists of one assistant response plus any resulting tool calls and results.

```json
{"type": "turn_start"}
```

```json
{
  "type": "turn_end",
  "message": {...},
  "toolResults": [...]
}
```

### message_start / message_end

Emitted when a message begins and completes. The `message` field contains an `AppMessage`.

```json
{"type": "message_start", "message": {...}}
{"type": "message_end", "message": {...}}
```

### message_update (Streaming)

Emitted during streaming of assistant messages. Contains both the partial message and a streaming delta event.

```json
{
  "type": "message_update",
  "message": {...},
  "assistantMessageEvent": {
    "type": "text_delta",
    "contentIndex": 0,
    "delta": "Hello ",
    "partial": {...}
  }
}
```

The `assistantMessageEvent` field contains one of these delta types:

| Type | Description |
|------|-------------|
| `start` | Message generation started |
| `text_start` | Text content block started |
| `text_delta` | Text content chunk |
| `text_end` | Text content block ended |
| `thinking_start` | Thinking block started |
| `thinking_delta` | Thinking content chunk |
| `thinking_end` | Thinking block ended |
| `toolcall_start` | Tool call started |
| `toolcall_delta` | Tool call arguments chunk |
| `toolcall_end` | Tool call ended (includes full `toolCall` object) |
| `done` | Message complete (reason: `"stop"`, `"length"`, `"toolUse"`) |
| `error` | Error occurred (reason: `"aborted"`, `"error"`) |

Example streaming a text response:
```json
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{...}}}
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello","partial":{...}}}
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world","partial":{...}}}
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world","partial":{...}}}
```

### tool_execution_start / tool_execution_update / tool_execution_end

Emitted when a tool begins, streams progress, and completes execution.

```json
{
  "type": "tool_execution_start",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "args": {"command": "ls -la"}
}
```

During execution, `tool_execution_update` events stream partial results (e.g., bash output as it arrives):

```json
{
  "type": "tool_execution_update",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "args": {"command": "ls -la"},
  "partialResult": {
    "content": [{"type": "text", "text": "partial output so far..."}],
    "details": {"truncation": null, "fullOutputPath": null}
  }
}
```

When complete:

```json
{
  "type": "tool_execution_end",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "result": {
    "content": [{"type": "text", "text": "total 48\n..."}],
    "details": {...}
  },
  "isError": false
}
```

Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_update` contains the accumulated output so far (not just the delta), allowing clients to simply replace their display on each update.

### auto_compaction_start / auto_compaction_end

Emitted when automatic compaction runs (when context is nearly full).

```json
{"type": "auto_compaction_start"}
```

```json
{
  "type": "auto_compaction_end",
  "result": {
    "tokensBefore": 150000,
    "summary": "Summary of conversation..."
  },
  "aborted": false
}
```

If compaction was aborted, `result` is `null` and `aborted` is `true`.

### auto_retry_start / auto_retry_end

Emitted when automatic retry is triggered after a transient error (overloaded, rate limit, 5xx).

```json
{
  "type": "auto_retry_start",
  "attempt": 1,
  "maxAttempts": 3,
  "delayMs": 2000,
  "errorMessage": "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
}
```

```json
{
  "type": "auto_retry_end",
  "success": true,
  "attempt": 2
}
```

On final failure (max retries exceeded):
```json
{
  "type": "auto_retry_end",
  "success": false,
  "attempt": 3,
  "finalError": "529 overloaded_error: Overloaded"
}
```

## Error Handling

Failed commands return a response with `success: false`:

```json
{
  "type": "response",
  "command": "set_model",
  "success": false,
  "error": "Model not found: invalid/model"
}
```

Parse errors:

```json
{
  "type": "response",
  "command": "parse",
  "success": false,
  "error": "Failed to parse command: Unexpected token..."
}
```

## Types

Source files:
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent`
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types

### Model

```json
{
  "id": "claude-sonnet-4-20250514",
  "name": "Claude Sonnet 4",
  "api": "anthropic-messages",
  "provider": "anthropic",
  "baseUrl": "https://api.anthropic.com",
  "reasoning": true,
  "input": ["text", "image"],
  "contextWindow": 200000,
  "maxTokens": 16384,
  "cost": {
    "input": 3.0,
    "output": 15.0,
    "cacheRead": 0.3,
    "cacheWrite": 3.75
  }
}
```

### UserMessage

```json
{
  "role": "user",
  "content": "Hello!",
  "timestamp": 1733234567890,
  "attachments": []
}
```

The `content` field can be a string or an array of `TextContent`/`ImageContent` blocks.

### AssistantMessage

```json
{
  "role": "assistant",
  "content": [
    {"type": "text", "text": "Hello! How can I help?"},
    {"type": "thinking", "thinking": "User is greeting me..."},
    {"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
  ],
  "api": "anthropic-messages",
  "provider": "anthropic",
  "model": "claude-sonnet-4-20250514",
  "usage": {
    "input": 100,
    "output": 50,
    "cacheRead": 0,
    "cacheWrite": 0,
    "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
  },
  "stopReason": "stop",
  "timestamp": 1733234567890
}
```

Stop reasons: `"stop"`, `"length"`, `"toolUse"`, `"error"`, `"aborted"`

### ToolResultMessage

```json
{
  "role": "toolResult",
  "toolCallId": "call_123",
  "toolName": "bash",
  "content": [{"type": "text", "text": "total 48\ndrwxr-xr-x ..."}],
  "isError": false,
  "timestamp": 1733234567890
}
```

### BashExecutionMessage

Created by the `bash` RPC command (not by LLM tool calls):

```json
{
  "role": "bashExecution",
  "command": "ls -la",
  "output": "total 48\ndrwxr-xr-x ...",
  "exitCode": 0,
  "cancelled": false,
  "truncated": false,
  "fullOutputPath": null,
  "timestamp": 1733234567890
}
```

### Attachment

```json
{
  "id": "img1",
  "type": "image",
  "fileName": "photo.jpg",
  "mimeType": "image/jpeg",
  "size": 102400,
  "content": "base64-encoded-data...",
  "extractedText": null,
  "preview": null
}
```

## Example: Basic Client (Python)

```python
import subprocess
import json

proc = subprocess.Popen(
    ["pi", "--mode", "rpc", "--no-session"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True
)

def send(cmd):
    proc.stdin.write(json.dumps(cmd) + "\n")
    proc.stdin.flush()

def read_events():
    for line in proc.stdout:
        yield json.loads(line)

# Send prompt
send({"type": "prompt", "message": "Hello!"})

# Process events
for event in read_events():
    if event.get("type") == "message_update":
        delta = event.get("assistantMessageEvent", {})
        if delta.get("type") == "text_delta":
            print(delta["delta"], end="", flush=True)

    if event.get("type") == "agent_end":
        print()
        break
```

## Example: Interactive Client (Node.js)

See [`test/rpc-example.ts`](../test/rpc-example.ts) for a complete interactive example, or [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts) for a typed client implementation.

```javascript
const { spawn } = require("child_process");
const readline = require("readline");

const agent = spawn("pi", ["--mode", "rpc", "--no-session"]);

readline.createInterface({ input: agent.stdout }).on("line", (line) => {
    const event = JSON.parse(line);

    if (event.type === "message_update") {
        const { assistantMessageEvent } = event;
        if (assistantMessageEvent.type === "text_delta") {
            process.stdout.write(assistantMessageEvent.delta);
        }
    }
});

// Send prompt
agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n");

// Abort on Ctrl+C
process.on("SIGINT", () => {
    agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
});
```


Now let me also check the README section on custom tools to compare:

bash

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


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


I see the issue. The README has two "Custom Tools" sections:
1. One under "Configuration" that describes the proper TypeScript custom tools
2. One under "Tools" that describes the lightweight bash/README approach

This is confusing. Let me check what the current structure looks like:

grep -n "^## \|^### " /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md | head -80


7:## Table of Contents
42:## Getting Started
44:### Installation
85:### Windows Setup
104:### API Keys
146:### Quick Start
163:## Usage
165:### Slash Commands
186:### Editor Features
198:### Keyboard Shortcuts
231:### Bash Mode
254:### Image Support
270:## Sessions
272:### Session Management
288:### Context Compaction
320:### Branching
331:## Configuration
333:### Project Context Files
358:### Custom Models and Providers
430:### Themes
451:### Custom Slash Commands
489:### Skills
552:### Hooks
606:### Custom Tools
654:### Settings File
692:## CLI Reference
698:### Options
722:### File Arguments
734:### Examples
773:## Tools
775:### Default Tools
784:### Read-Only Tools
796:### Custom Tools
805:## Usage
825:## Programmatic Usage
827:### RPC Mode
845:### HTML Export
856:## Philosophy
860:### No MCP
868:### No Sub-Agents
872:### No Built-in To-Dos
882:### No Planning Mode
888:## Goal
890:## Current Step
894:### No Permission System (YOLO Mode)
908:### No Background Bash
914:## Development
916:### Forking / Rebranding
931:### Path Resolution
943:### Debug Command
951:## License
955:## See Also


I see. The second "Custom Tools" under "Tools" is the lightweight bash approach. Let me look at it more closely:

sed -n '796,825p' /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


The second "Custom Tools" section (the bash/README approach) should probably be renamed to something like "CLI Tool Pattern" or "Bash-based Tools" to differentiate it from the TypeScript Custom Tools feature.

Let me summarize my findings:

**Documentation status:**

1. **theme.md** - Very comprehensive, looks up to date
2. **skills.md** - Good and concise
3. **hooks.md** - Very comprehensive, looks up to date
4. **custom-tools.md** - Comprehensive, looks up to date
5. **rpc.md** - Very comprehensive, looks up to date

**Issues found:**

1. **README.md has duplicate "Custom Tools" sections** - one for TypeScript custom tools (under Configuration), one for bash CLI tools (under Tools). The second one should be renamed.

2. **System prompt update** - Done, but it's quite terse. The mapping is clear though.

Do you want me to:
1. Rename the second "Custom Tools" section in README.md to avoid confusion (e.g., "CLI Tool Pattern" or "Bash Tools")?
2. Run `npm run check` to verify the system-prompt.ts change compiles?


my dude, how can you say things look up-to-date if you haven't checked the source code ... and matched what's int he .md files against it. let's start with theme.md


You're right, that was lazy. Let me actually check the theme implementation against the docs.

find /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src -name "*theme*" -type f


total 72
drwxr-xr-x@ 6 badlogic  staff    192 Dec 15 23:31 .
drwxr-xr-x@ 5 badlogic  staff    160 Dec 17 16:12 ..
-rw-r--r--@ 1 badlogic  staff   1673 Dec  9 13:39 dark.json
-rw-r--r--@ 1 badlogic  staff   1705 Dec  9 13:39 light.json
-rw-r--r--@ 1 badlogic  staff   6995 Dec  9 13:39 theme-schema.json
-rw-r--r--@ 1 badlogic  staff  19959 Dec 15 23:31 theme.ts


import * as fs from "node:fs";
import * as path from "node:path";
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk";
import { getCustomThemesDir, getThemesDir } from "../../../config.js";

// ============================================================================
// Types & Schema
// ============================================================================

const ColorValueSchema = Type.Union([
	Type.String(), // hex "#ff0000", var ref "primary", or empty ""
	Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
]);

type ColorValue = Static<typeof ColorValueSchema>;

const ThemeJsonSchema = Type.Object({
	$schema: Type.Optional(Type.String()),
	name: Type.String(),
	vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
	colors: Type.Object({
		// Core UI (10 colors)
		accent: ColorValueSchema,
		border: ColorValueSchema,
		borderAccent: ColorValueSchema,
		borderMuted: ColorValueSchema,
		success: ColorValueSchema,
		error: ColorValueSchema,
		warning: ColorValueSchema,
		muted: ColorValueSchema,
		dim: ColorValueSchema,
		text: ColorValueSchema,
		// Backgrounds & Content Text (7 colors)
		userMessageBg: ColorValueSchema,
		userMessageText: ColorValueSchema,
		toolPendingBg: ColorValueSchema,
		toolSuccessBg: ColorValueSchema,
		toolErrorBg: ColorValueSchema,
		toolTitle: ColorValueSchema,
		toolOutput: ColorValueSchema,
		// Markdown (10 colors)
		mdHeading: ColorValueSchema,
		mdLink: ColorValueSchema,
		mdLinkUrl: ColorValueSchema,
		mdCode: ColorValueSchema,
		mdCodeBlock: ColorValueSchema,
		mdCodeBlockBorder: ColorValueSchema,
		mdQuote: ColorValueSchema,
		mdQuoteBorder: ColorValueSchema,
		mdHr: ColorValueSchema,
		mdListBullet: ColorValueSchema,
		// Tool Diffs (3 colors)
		toolDiffAdded: ColorValueSchema,
		toolDiffRemoved: ColorValueSchema,
		toolDiffContext: ColorValueSchema,
		// Syntax Highlighting (9 colors)
		syntaxComment: ColorValueSchema,
		syntaxKeyword: ColorValueSchema,
		syntaxFunction: ColorValueSchema,
		syntaxVariable: ColorValueSchema,
		syntaxString: ColorValueSchema,
		syntaxNumber: ColorValueSchema,
		syntaxType: ColorValueSchema,
		syntaxOperator: ColorValueSchema,
		syntaxPunctuation: ColorValueSchema,
		// Thinking Level Borders (6 colors)
		thinkingOff: ColorValueSchema,
		thinkingMinimal: ColorValueSchema,
		thinkingLow: ColorValueSchema,
		thinkingMedium: ColorValueSchema,
		thinkingHigh: ColorValueSchema,
		thinkingXhigh: ColorValueSchema,
		// Bash Mode (1 color)
		bashMode: ColorValueSchema,
	}),
});

type ThemeJson = Static<typeof ThemeJsonSchema>;

const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);

export type ThemeColor =
	| "accent"
	| "border"
	| "borderAccent"
	| "borderMuted"
	| "success"
	| "error"
	| "warning"
	| "muted"
	| "dim"
	| "text"
	| "userMessageText"
	| "toolTitle"
	| "toolOutput"
	| "mdHeading"
	| "mdLink"
	| "mdLinkUrl"
	| "mdCode"
	| "mdCodeBlock"
	| "mdCodeBlockBorder"
	| "mdQuote"
	| "mdQuoteBorder"
	| "mdHr"
	| "mdListBullet"
	| "toolDiffAdded"
	| "toolDiffRemoved"
	| "toolDiffContext"
	| "syntaxComment"
	| "syntaxKeyword"
	| "syntaxFunction"
	| "syntaxVariable"
	| "syntaxString"
	| "syntaxNumber"
	| "syntaxType"
	| "syntaxOperator"
	| "syntaxPunctuation"
	| "thinkingOff"
	| "thinkingMinimal"
	| "thinkingLow"
	| "thinkingMedium"
	| "thinkingHigh"
	| "thinkingXhigh"
	| "bashMode";

export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";

type ColorMode = "truecolor" | "256color";

// ============================================================================
// Color Utilities
// ============================================================================

function detectColorMode(): ColorMode {
	const colorterm = process.env.COLORTERM;
	if (colorterm === "truecolor" || colorterm === "24bit") {
		return "truecolor";
	}
	// Windows Terminal supports truecolor
	if (process.env.WT_SESSION) {
		return "truecolor";
	}
	const term = process.env.TERM || "";
	if (term.includes("256color")) {
		return "256color";
	}
	return "256color";
}

function hexToRgb(hex: string): { r: number; g: number; b: number } {
	const cleaned = hex.replace("#", "");
	if (cleaned.length !== 6) {
		throw new Error(`Invalid hex color: ${hex}`);
	}
	const r = parseInt(cleaned.substring(0, 2), 16);
	const g = parseInt(cleaned.substring(2, 4), 16);
	const b = parseInt(cleaned.substring(4, 6), 16);
	if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
		throw new Error(`Invalid hex color: ${hex}`);
	}
	return { r, g, b };
}

// The 6x6x6 color cube channel values (indices 0-5)
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];

// Grayscale ramp values (indices 232-255, 24 grays from 8 to 238)
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);

function findClosestCubeIndex(value: number): number {
	let minDist = Infinity;
	let minIdx = 0;
	for (let i = 0; i < CUBE_VALUES.length; i++) {
		const dist = Math.abs(value - CUBE_VALUES[i]);
		if (dist < minDist) {
			minDist = dist;
			minIdx = i;
		}
	}
	return minIdx;
}

function findClosestGrayIndex(gray: number): number {
	let minDist = Infinity;
	let minIdx = 0;
	for (let i = 0; i < GRAY_VALUES.length; i++) {
		const dist = Math.abs(gray - GRAY_VALUES[i]);
		if (dist < minDist) {
			minDist = dist;
			minIdx = i;
		}
	}
	return minIdx;
}

function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
	// Weighted Euclidean distance (human eye is more sensitive to green)
	const dr = r1 - r2;
	const dg = g1 - g2;
	const db = b1 - b2;
	return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
}

function rgbTo256(r: number, g: number, b: number): number {
	// Find closest color in the 6x6x6 cube
	const rIdx = findClosestCubeIndex(r);
	const gIdx = findClosestCubeIndex(g);
	const bIdx = findClosestCubeIndex(b);
	const cubeR = CUBE_VALUES[rIdx];
	const cubeG = CUBE_VALUES[gIdx];
	const cubeB = CUBE_VALUES[bIdx];
	const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
	const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);

	// Find closest grayscale
	const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
	const grayIdx = findClosestGrayIndex(gray);
	const grayValue = GRAY_VALUES[grayIdx];
	const grayIndex = 232 + grayIdx;
	const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);

	// Check if color has noticeable saturation (hue matters)
	// If max-min spread is significant, prefer cube to preserve tint
	const maxC = Math.max(r, g, b);
	const minC = Math.min(r, g, b);
	const spread = maxC - minC;

	// Only consider grayscale if color is nearly neutral (spread < 10)
	// AND grayscale is actually closer
	if (spread < 10 && grayDist < cubeDist) {
		return grayIndex;
	}

	return cubeIndex;
}

function hexTo256(hex: string): number {
	const { r, g, b } = hexToRgb(hex);
	return rgbTo256(r, g, b);
}

function fgAnsi(color: string | number, mode: ColorMode): string {
	if (color === "") return "\x1b[39m";
	if (typeof color === "number") return `\x1b[38;5;${color}m`;
	if (color.startsWith("#")) {
		if (mode === "truecolor") {
			const { r, g, b } = hexToRgb(color);
			return `\x1b[38;2;${r};${g};${b}m`;
		} else {
			const index = hexTo256(color);
			return `\x1b[38;5;${index}m`;
		}
	}
	throw new Error(`Invalid color value: ${color}`);
}

function bgAnsi(color: string | number, mode: ColorMode): string {
	if (color === "") return "\x1b[49m";
	if (typeof color === "number") return `\x1b[48;5;${color}m`;
	if (color.startsWith("#")) {
		if (mode === "truecolor") {
			const { r, g, b } = hexToRgb(color);
			return `\x1b[48;2;${r};${g};${b}m`;
		} else {
			const index = hexTo256(color);
			return `\x1b[48;5;${index}m`;
		}
	}
	throw new Error(`Invalid color value: ${color}`);
}

function resolveVarRefs(
	value: ColorValue,
	vars: Record<string, ColorValue>,
	visited = new Set<string>(),
): string | number {
	if (typeof value === "number" || value === "" || value.startsWith("#")) {
		return value;
	}
	if (visited.has(value)) {
		throw new Error(`Circular variable reference detected: ${value}`);
	}
	if (!(value in vars)) {
		throw new Error(`Variable reference not found: ${value}`);
	}
	visited.add(value);
	return resolveVarRefs(vars[value], vars, visited);
}

function resolveThemeColors<T extends Record<string, ColorValue>>(
	colors: T,
	vars: Record<string, ColorValue> = {},
): Record<keyof T, string | number> {
	const resolved: Record<string, string | number> = {};
	for (const [key, value] of Object.entries(colors)) {
		resolved[key] = resolveVarRefs(value, vars);
	}
	return resolved as Record<keyof T, string | number>;
}

// ============================================================================
// Theme Class
// ============================================================================

export class Theme {
	private fgColors: Map<ThemeColor, string>;
	private bgColors: Map<ThemeBg, string>;
	private mode: ColorMode;

	constructor(
		fgColors: Record<ThemeColor, string | number>,
		bgColors: Record<ThemeBg, string | number>,
		mode: ColorMode,
	) {
		this.mode = mode;
		this.fgColors = new Map();
		for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
			this.fgColors.set(key, fgAnsi(value, mode));
		}
		this.bgColors = new Map();
		for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
			this.bgColors.set(key, bgAnsi(value, mode));
		}
	}

	fg(color: ThemeColor, text: string): string {
		const ansi = this.fgColors.get(color);
		if (!ansi) throw new Error(`Unknown theme color: ${color}`);
		return `${ansi}${text}\x1b[39m`; // Reset only foreground color
	}

	bg(color: ThemeBg, text: string): string {
		const ansi = this.bgColors.get(color);
		if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
		return `${ansi}${text}\x1b[49m`; // Reset only background color
	}

	bold(text: string): string {
		return chalk.bold(text);
	}

	italic(text: string): string {
		return chalk.italic(text);
	}

	underline(text: string): string {
		return chalk.underline(text);
	}

	getFgAnsi(color: ThemeColor): string {
		const ansi = this.fgColors.get(color);
		if (!ansi) throw new Error(`Unknown theme color: ${color}`);
		return ansi;
	}

	getBgAnsi(color: ThemeBg): string {
		const ansi = this.bgColors.get(color);
		if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
		return ansi;
	}

	getColorMode(): ColorMode {
		return this.mode;
	}

	getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (str: string) => string {
		// Map thinking levels to dedicated theme colors
		switch (level) {
			case "off":
				return (str: string) => this.fg("thinkingOff", str);
			case "minimal":
				return (str: string) => this.fg("thinkingMinimal", str);
			case "low":
				return (str: string) => this.fg("thinkingLow", str);
			case "medium":
				return (str: string) => this.fg("thinkingMedium", str);
			case "high":
				return (str: string) => this.fg("thinkingHigh", str);
			case "xhigh":
				return (str: string) => this.fg("thinkingXhigh", str);
			default:
				return (str: string) => this.fg("thinkingOff", str);
		}
	}

	getBashModeBorderColor(): (str: string) => string {
		return (str: string) => this.fg("bashMode", str);
	}
}

// ============================================================================
// Theme Loading
// ============================================================================

let BUILTIN_THEMES: Record<string, ThemeJson> | undefined;

function getBuiltinThemes(): Record<string, ThemeJson> {
	if (!BUILTIN_THEMES) {
		const themesDir = getThemesDir();
		const darkPath = path.join(themesDir, "dark.json");
		const lightPath = path.join(themesDir, "light.json");
		BUILTIN_THEMES = {
			dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson,
			light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson,
		};
	}
	return BUILTIN_THEMES;
}

export function getAvailableThemes(): string[] {
	const themes = new Set<string>(Object.keys(getBuiltinThemes()));
	const customThemesDir = getCustomThemesDir();
	if (fs.existsSync(customThemesDir)) {
		const files = fs.readdirSync(customThemesDir);
		for (const file of files) {
			if (file.endsWith(".json")) {
				themes.add(file.slice(0, -5));
			}
		}
	}
	return Array.from(themes).sort();
}

function loadThemeJson(name: string): ThemeJson {
	const builtinThemes = getBuiltinThemes();
	if (name in builtinThemes) {
		return builtinThemes[name];
	}
	const customThemesDir = getCustomThemesDir();
	const themePath = path.join(customThemesDir, `${name}.json`);
	if (!fs.existsSync(themePath)) {
		throw new Error(`Theme not found: ${name}`);
	}
	const content = fs.readFileSync(themePath, "utf-8");
	let json: unknown;
	try {
		json = JSON.parse(content);
	} catch (error) {
		throw new Error(`Failed to parse theme ${name}: ${error}`);
	}
	if (!validateThemeJson.Check(json)) {
		const errors = Array.from(validateThemeJson.Errors(json));
		const missingColors: string[] = [];
		const otherErrors: string[] = [];

		for (const e of errors) {
			// Check for missing required color properties
			const match = e.path.match(/^\/colors\/(\w+)$/);
			if (match && e.message.includes("Required")) {
				missingColors.push(match[1]);
			} else {
				otherErrors.push(`  - ${e.path}: ${e.message}`);
			}
		}

		let errorMessage = `Invalid theme "${name}":\n`;
		if (missingColors.length > 0) {
			errorMessage += `\nMissing required color tokens:\n`;
			errorMessage += missingColors.map((c) => `  - ${c}`).join("\n");
			errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
			errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
		}
		if (otherErrors.length > 0) {
			errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
		}

		throw new Error(errorMessage);
	}
	return json as ThemeJson;
}

function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
	const colorMode = mode ?? detectColorMode();
	const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
	const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
	const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
	const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
	for (const [key, value] of Object.entries(resolvedColors)) {
		if (bgColorKeys.has(key)) {
			bgColors[key as ThemeBg] = value;
		} else {
			fgColors[key as ThemeColor] = value;
		}
	}
	return new Theme(fgColors, bgColors, colorMode);
}

function loadTheme(name: string, mode?: ColorMode): Theme {
	const themeJson = loadThemeJson(name);
	return createTheme(themeJson, mode);
}

function detectTerminalBackground(): "dark" | "light" {
	const colorfgbg = process.env.COLORFGBG || "";
	if (colorfgbg) {
		const parts = colorfgbg.split(";");
		if (parts.length >= 2) {
			const bg = parseInt(parts[1], 10);
			if (!Number.isNaN(bg)) {
				const result = bg < 8 ? "dark" : "light";
				return result;
			}
		}
	}
	return "dark";
}

function getDefaultTheme(): string {
	return detectTerminalBackground();
}

// ============================================================================
// Global Theme Instance
// ============================================================================

export let theme: Theme;
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;

export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		if (enableWatcher) {
			startThemeWatcher();
		}
	} catch (error) {
		// Theme is invalid - fall back to dark theme silently
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
	}
}

export function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } {
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		if (enableWatcher) {
			startThemeWatcher();
		}
		return { success: true };
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
		return {
			success: false,
			error: error instanceof Error ? error.message : String(error),
		};
	}
}

export function onThemeChange(callback: () => void): void {
	onThemeChangeCallback = callback;
}

function startThemeWatcher(): void {
	// Stop existing watcher if any
	if (themeWatcher) {
		themeWatcher.close();
		themeWatcher = undefined;
	}

	// Only watch if it's a custom theme (not built-in)
	if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
		return;
	}

	const customThemesDir = getCustomThemesDir();
	const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);

	// Only watch if the file exists
	if (!fs.existsSync(themeFile)) {
		return;
	}

	try {
		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
					}
				}, 100);
			} else if (eventType === "rename") {
				// File was deleted or renamed - fall back to default theme
				setTimeout(() => {
					if (!fs.existsSync(themeFile)) {
						currentThemeName = "dark";
						theme = loadTheme("dark");
						if (themeWatcher) {
							themeWatcher.close();
							themeWatcher = undefined;
						}
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					}
				}, 100);
			}
		});
	} catch (error) {
		// Ignore errors starting watcher
	}
}

export function stopThemeWatcher(): void {
	if (themeWatcher) {
		themeWatcher.close();
		themeWatcher = undefined;
	}
}

// ============================================================================
// TUI Helpers
// ============================================================================

export function getMarkdownTheme(): MarkdownTheme {
	return {
		heading: (text: string) => theme.fg("mdHeading", text),
		link: (text: string) => theme.fg("mdLink", text),
		linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
		code: (text: string) => theme.fg("mdCode", text),
		codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
		codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
		quote: (text: string) => theme.fg("mdQuote", text),
		quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
		hr: (text: string) => theme.fg("mdHr", text),
		listBullet: (text: string) => theme.fg("mdListBullet", text),
		bold: (text: string) => theme.bold(text),
		italic: (text: string) => theme.italic(text),
		underline: (text: string) => theme.underline(text),
		strikethrough: (text: string) => chalk.strikethrough(text),
	};
}

export function getSelectListTheme(): SelectListTheme {
	return {
		selectedPrefix: (text: string) => theme.fg("accent", text),
		selectedText: (text: string) => theme.fg("accent", text),
		description: (text: string) => theme.fg("muted", text),
		scrollInfo: (text: string) => theme.fg("muted", text),
		noMatch: (text: string) => theme.fg("muted", text),
	};
}

export function getEditorTheme(): EditorTheme {
	return {
		borderColor: (text: string) => theme.fg("borderMuted", text),
		selectList: getSelectListTheme(),
	};
}


Now let me check the dark.json to see actual color tokens:


Now I can compare. Here are the discrepancies between theme.md and the actual source:

**Issues in theme.md:**

1. **Missing color tokens:**
   - `thinkingXhigh` - not documented (docs say 5 thinking colors, but there are 6)
   - `bashMode` - not documented at all

2. **Wrong count:** Doc says "Total: 44 color tokens" but actual count is 46 (44 + thinkingXhigh + bashMode)

3. **Thinking Level Borders section** says "(5 colors)" but there are 6

4. **Missing `bashMode` section** - there's a "Bash Mode (1 color)" category in the schema but not documented

5. **Doc mentions `toolText`** in Backgrounds & Content Text section but schema uses `toolTitle` and `toolOutput`

6. **Theme class methods** - docs show `dim()` method but the actual implementation doesn't have `dim()` as a method (it has `bold()`, `italic()`, `underline()`)

Let me also verify a few other claims in the docs:

grep -n "dim\|underline" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/theme/theme.ts | head -20


34:		dim: ColorValueSchema,
94:	| "dim"
349:	underline(text: string): string {
350:		return chalk.underline(text);
647:		underline: (text: string) => theme.underline(text),


Now let me update theme.md with all the fixes:


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

[487 more lines in file. Use offset=101 to continue]


edit

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

### Thinking Level Borders (6 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 |
| `thinkingXhigh` | Border for xhigh thinking (most prominent, OpenAI codex-max only) |

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

### Bash Mode (1 color)

| Token | Purpose |
|-------|---------|
| `bashMode` | Editor border color when in bash mode (! prefix) |

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/theme.md. Changed 536 characters to 747 characters.

     ...
  73 | `syntaxType` | Type names |
  74 | `syntaxOperator` | Operators (`+`, `-`, etc) |
  75 | `syntaxPunctuation` | Punctuation (`;`, `,`, etc) |
  76
- 77 ### Thinking Level Borders (5 colors)
+ 77 ### Thinking Level Borders (6 colors)
  78
  79 Editor border colors that indicate the current thinking/reasoning level:
  80
  81 | Token | Purpose |
  82 |-------|---------|
  83 | `thinkingOff` | Border when thinking is off (most subtle) |
  84 | `thinkingMinimal` | Border for minimal thinking |
  85 | `thinkingLow` | Border for low thinking |
  86 | `thinkingMedium` | Border for medium thinking |
- 87 | `thinkingHigh` | Border for high thinking (most prominent) |
+ 87 | `thinkingHigh` | Border for high thinking |
+ 88 | `thinkingXhigh` | Border for xhigh thinking (most prominent, OpenAI codex-max only) |
  88
- 89 These create a visual hierarchy: off → minimal → low → medium → high
+ 90 These create a visual hierarchy: off → minimal → low → medium → high → xhigh
  90
- 91 **Total: 44 color tokens** (all required)
+ 92 ### Bash Mode (1 color)
  92
+ 94 | Token | Purpose |
+ 95 |-------|---------|
+ 96 | `bashMode` | Editor border color when in bash mode (! prefix) |
+ 97
+ 98 **Total: 46 color tokens** (all required)
+ 99
  93 ## Theme Format
  94
  95 Themes are defined in JSON files with the following structure:
  96
     ...


Now let me check the Implementation section for the `dim()` method issue:


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

[95 more lines in file. Use offset=500 to continue]


I see the Theme class docs show `dim(text: string)` but the actual implementation has `underline()` instead. Let me fix that:

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

```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
  italic(text: string): string
  underline(text: string): string
}
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/theme.md. Changed 319 characters to 325 characters.

     ...
 426   bg(color: ThemeBg, text: string): string
 427
 428   // Text attributes (preserve current colors)
 429   bold(text: string): string
-430   dim(text: string): string
 431   italic(text: string): string
+431   underline(text: string): string
 432 }
 433 ```
 434
 435 ### Global Theme Instance
     ...


Now let me check the MarkdownTheme interface in the actual pi-tui package to verify it matches:

bash

grep -A 30 "export interface MarkdownTheme" /Users/badlogic/workspaces/pi-mono/packages/tui/src/*.ts


(no output)

Command exited with code 1


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


grep -A 25 "export interface MarkdownTheme" /Users/badlogic/workspaces/pi-mono/packages/tui/src/components/markdown.ts


export interface MarkdownTheme {
	heading: (text: string) => string;
	link: (text: string) => string;
	linkUrl: (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;
	bold: (text: string) => string;
	italic: (text: string) => string;
	strikethrough: (text: string) => string;
	underline: (text: string) => string;
}

export class Markdown implements Component {
	private text: string;
	private paddingX: number; // Left/right padding
	private paddingY: number; // Top/bottom padding
	private defaultTextStyle?: DefaultTextStyle;
	private theme: MarkdownTheme;
	private defaultStylePrefix?: string;

	// Cache for rendered output


The actual MarkdownTheme has more fields than documented. Let me update the docs:

edit

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

```typescript
// In @mariozechner/pi-tui
export interface MarkdownTheme {
  heading: (text: string) => string;
  link: (text: string) => string;
  linkUrl: (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;
  bold: (text: string) => string;
  italic: (text: string) => string;
  strikethrough: (text: string) => string;
  underline: (text: string) => string;
}
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/theme.md. Changed 738 characters to 605 characters.

     ...
 459 // In @mariozechner/pi-tui
 460 export interface MarkdownTheme {
 461   heading: (text: string) => string;
 462   link: (text: string) => string;
+463   linkUrl: (text: string) => string;
 463   code: (text: string) => string;
 464   codeBlock: (text: string) => string;
 465   codeBlockBorder: (text: string) => string;
 466   quote: (text: string) => string;
 467   quoteBorder: (text: string) => string;
 468   hr: (text: string) => string;
 469   listBullet: (text: string) => string;
+471   bold: (text: string) => string;
+472   italic: (text: string) => string;
+473   strikethrough: (text: string) => string;
+474   underline: (text: string) => string;
 470 }
-471
-472 export class Markdown {
-473   constructor(
-474     text: string,
-475     paddingX: number,
-476     paddingY: number,
-477     defaultTextStyle?: DefaultTextStyle,
-478     theme?: MarkdownTheme  // Optional theme functions
-479   )
-480
-481   // Usage in component
-482   renderHeading(text: string) {
-483     return this.theme.heading(text);  // Applies color
-484   }
-485 }
 486 ```
 487
 488 The `coding-agent` provides themed functions when creating components:
 489
     ...


Now let me also fix the "getMarkdownTheme" example in the docs to match the actual implementation:


  listBullet: (text: string) => string;
  bold: (text: string) => string;
  italic: (text: string) => string;
  strikethrough: (text: string) => string;
  underline: (text: string) => string;
}
```

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
   {

[35 more lines in file. Use offset=550 to continue]


I need to fix the getMarkdownTheme example and the `theme.dim` example (should be `theme.italic` or similar since there's no `dim()` method):

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

// Helper to create markdown theme functions
function getMarkdownTheme(): MarkdownTheme {
  return {
    heading: (text) => theme.fg('mdHeading', text),
    link: (text) => theme.fg('mdLink', text),
    linkUrl: (text) => theme.fg('mdLinkUrl', 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),
    bold: (text) => theme.bold(text),
    italic: (text) => theme.italic(text),
    underline: (text) => theme.underline(text),
    strikethrough: (text) => chalk.strikethrough(text),
  };
}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/theme.md. Changed 583 characters to 819 characters.

     ...
 486 function getMarkdownTheme(): MarkdownTheme {
 487   return {
 488     heading: (text) => theme.fg('mdHeading', text),
 489     link: (text) => theme.fg('mdLink', text),
+490     linkUrl: (text) => theme.fg('mdLinkUrl', text),
 490     code: (text) => theme.fg('mdCode', text),
 491     codeBlock: (text) => theme.fg('mdCodeBlock', text),
 492     codeBlockBorder: (text) => theme.fg('mdCodeBlockBorder', text),
 493     quote: (text) => theme.fg('mdQuote', text),
 494     quoteBorder: (text) => theme.fg('mdQuoteBorder', text),
 495     hr: (text) => theme.fg('mdHr', text),
 496     listBullet: (text) => theme.fg('mdListBullet', text),
+498     bold: (text) => theme.bold(text),
+499     italic: (text) => theme.italic(text),
+500     underline: (text) => theme.underline(text),
+501     strikethrough: (text) => chalk.strikethrough(text),
 497   };
 498 }
 499
 500 // Create markdown with theme
     ...


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

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


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/theme.md. Changed 98 characters to 101 characters.

     ...
 531 theme.bg('toolSuccessBg', output)
 532
 533 // Combine styles
 534 theme.bold(theme.fg('accent', 'Title'))
-535 theme.dim(theme.fg('muted', 'metadata'))
+535 theme.italic(theme.fg('muted', 'metadata'))
 536
 537 // Nested foreground + background
 538 const userMsg = theme.bg('userMessageBg',
 539   theme.fg('userMessageText', 'Hello')
     ...


Now let me run the check to make sure everything compiles:

bash

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


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

Checked 273 files in 229ms. 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 60ms. 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 21ms. No fixes applied.

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

Checked 3 files in 6ms. No fixes applied.

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

Checked 26 files in 42ms. 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 summarize the changes I made to theme.md:

**Changes made to theme.md:**

1. Fixed "Thinking Level Borders" section: 5 colors → 6 colors, added `thinkingXhigh`
2. Added new "Bash Mode (1 color)" section with `bashMode` token
3. Fixed total count: 44 → 46 color tokens
4. Fixed Theme class methods: replaced `dim()` with `underline()`
5. Updated MarkdownTheme interface to include all fields: `linkUrl`, `bold`, `italic`, `strikethrough`, `underline`
6. Updated getMarkdownTheme example to include all required fields
7. Fixed example: `theme.dim()` → `theme.italic()`

Now let's move on to skills.md. Let me check the skills implementation:

find /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src -name "*skill*" -type f


import { existsSync, readdirSync, readFileSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join, resolve } from "path";
import { CONFIG_DIR_NAME } from "../config.js";

export interface SkillFrontmatter {
	name?: string;
	description: string;
}

export interface Skill {
	name: string;
	description: string;
	filePath: string;
	baseDir: string;
	source: string;
}

type SkillFormat = "recursive" | "claude";

function stripQuotes(value: string): string {
	if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
		return value.slice(1, -1);
	}
	return value;
}

function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
	const frontmatter: SkillFrontmatter = { description: "" };

	const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");

	if (!normalizedContent.startsWith("---")) {
		return { frontmatter, body: normalizedContent };
	}

	const endIndex = normalizedContent.indexOf("\n---", 3);
	if (endIndex === -1) {
		return { frontmatter, body: normalizedContent };
	}

	const frontmatterBlock = normalizedContent.slice(4, endIndex);
	const body = normalizedContent.slice(endIndex + 4).trim();

	for (const line of frontmatterBlock.split("\n")) {
		const match = line.match(/^(\w+):\s*(.*)$/);
		if (match) {
			const key = match[1];
			const value = stripQuotes(match[2].trim());
			if (key === "name") {
				frontmatter.name = value;
			} else if (key === "description") {
				frontmatter.description = value;
			}
		}
	}

	return { frontmatter, body };
}

export interface LoadSkillsFromDirOptions {
	/** Directory to scan for skills */
	dir: string;
	/** Source identifier for these skills */
	source: string;
	/** Use colon-separated path names (e.g., db:migrate) instead of simple directory name */
	useColonPath?: boolean;
}

/**
 * Load skills from a directory recursively.
 * Skills are directories containing a SKILL.md file with frontmatter including a description.
 */
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions, subdir: string = ""): Skill[] {
	const { dir, source, useColonPath = false } = options;
	return loadSkillsFromDirInternal(dir, source, "recursive", useColonPath, subdir);
}

function loadSkillsFromDirInternal(
	dir: string,
	source: string,
	format: SkillFormat,
	useColonPath: boolean = false,
	subdir: string = "",
): Skill[] {
	const skills: Skill[] = [];

	if (!existsSync(dir)) {
		return skills;
	}

	try {
		const entries = readdirSync(dir, { withFileTypes: true });

		for (const entry of entries) {
			if (entry.name.startsWith(".")) {
				continue;
			}

			if (entry.isSymbolicLink()) {
				continue;
			}

			const fullPath = join(dir, entry.name);

			if (format === "recursive") {
				// Recursive format: scan directories, look for SKILL.md files
				if (entry.isDirectory()) {
					const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
					skills.push(...loadSkillsFromDirInternal(fullPath, source, format, useColonPath, newSubdir));
				} else if (entry.isFile() && entry.name === "SKILL.md") {
					try {
						const rawContent = readFileSync(fullPath, "utf-8");
						const { frontmatter } = parseFrontmatter(rawContent);

						if (!frontmatter.description) {
							continue;
						}

						const skillDir = dirname(fullPath);
						// useColonPath: db:migrate (pi), otherwise just: migrate (codex)
						const nameFromPath = useColonPath ? subdir || basename(skillDir) : basename(skillDir);
						const name = frontmatter.name || nameFromPath;

						skills.push({
							name,
							description: frontmatter.description,
							filePath: fullPath,
							baseDir: skillDir,
							source,
						});
					} catch {}
				}
			} else if (format === "claude") {
				// Claude format: only one level deep, each directory must contain SKILL.md
				if (!entry.isDirectory()) {
					continue;
				}

				const skillDir = fullPath;
				const skillFile = join(skillDir, "SKILL.md");

				if (!existsSync(skillFile)) {
					continue;
				}

				try {
					const rawContent = readFileSync(skillFile, "utf-8");
					const { frontmatter } = parseFrontmatter(rawContent);

					if (!frontmatter.description) {
						continue;
					}

					const name = frontmatter.name || entry.name;

					skills.push({
						name,
						description: frontmatter.description,
						filePath: skillFile,
						baseDir: skillDir,
						source,
					});
				} catch {}
			}
		}
	} catch {}

	return skills;
}

/**
 * Format skills for inclusion in a system prompt.
 */
export function formatSkillsForPrompt(skills: Skill[]): string {
	if (skills.length === 0) {
		return "";
	}

	const lines = [
		"\n\n<available_skills>",
		"The following skills provide specialized instructions for specific tasks.",
		"Use the read tool to load a skill's file when the task matches its description.",
		"Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n",
	];

	for (const skill of skills) {
		lines.push(`- ${skill.name}: ${skill.description}`);
		lines.push(`  File: ${skill.filePath}`);
		lines.push(`  Base directory: ${skill.baseDir}`);
	}

	lines.push("</available_skills>");

	return lines.join("\n");
}

export function loadSkills(): Skill[] {
	const skillMap = new Map<string, Skill>();

	// Codex: recursive, simple directory name
	const codexUserDir = join(homedir(), ".codex", "skills");
	for (const skill of loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive", false)) {
		skillMap.set(skill.name, skill);
	}

	// Claude: single level only
	const claudeUserDir = join(homedir(), ".claude", "skills");
	for (const skill of loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude", false)) {
		skillMap.set(skill.name, skill);
	}

	const claudeProjectDir = resolve(process.cwd(), ".claude", "skills");
	for (const skill of loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude", false)) {
		skillMap.set(skill.name, skill);
	}

	// Pi: recursive, colon-separated path names
	const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills");
	for (const skill of loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive", true)) {
		skillMap.set(skill.name, skill);
	}

	const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills");
	for (const skill of loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive", true)) {
		skillMap.set(skill.name, skill);
	}

	return Array.from(skillMap.values());
}


Now let me compare with the docs. The docs say:

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)

Looking at the code, the order matches. Let me verify the frontmatter parsing and look for any other issues:

grep -n "CONFIG_DIR_NAME" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/config.ts | head -5


90:export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi";
102:	return process.env[ENV_AGENT_DIR] || join(homedir(), CONFIG_DIR_NAME, "agent");


first we need to better define what a fucking skill is and when to create one. let's thinkg first, before we change anything


The user wants to think through what a skill actually is and when someone should create one, before making any documentation changes. Let me think about this.

Looking at the current implementation and docs:

**What is a skill currently?**
- A SKILL.md file with a description in frontmatter
- Listed in the system prompt with descriptions
- Agent uses `read` tool to load full instructions when needed
- Can contain `{baseDir}` placeholder for referencing files in the skill's directory

**But what IS it conceptually?**
- It's essentially a set of instructions that get loaded on-demand
- It's NOT a tool (doesn't execute code)
- It's NOT a slash command (user doesn't invoke it directly)
- It's more like... contextual documentation or instructions that the agent loads when relevant

**When should you create a skill vs other things?**

- **Skill**: Instructions for how to do something that the agent should know about but doesn't need in every conversation. E.g., "how to work with our PDF processing scripts", "how to deploy to production", "how to use our internal CLI tools"

- **Custom Tool**: When you need actual code execution with parameters, return values, TUI rendering, session state. E.g., a database query tool, a web scraper, something that needs to run TypeScript code.

- **Slash Command**: When the USER wants to trigger a specific prompt. E.g., "/review" to review code, "/test" to run tests. User-initiated, not agent-decided.

- **AGENTS.md**: Always-loaded context. Project conventions, coding standards, common commands. Should be small because it's always in context.

**The key distinction for skills:**
- Agent-initiated loading (not user-initiated like slash commands)
- Instructions only (not executable code like custom tools)
- On-demand loading (not always-present like AGENTS.md)

**When to use a skill:**
1. You have specialized instructions that are too big to put in AGENTS.md

2. Instructions are task-specific and not universally applicable
3. I want the agent to intelligently load context based on relevance
4. There are accompanying helper resources that provide additional guidance

Ideal scenarios include specialized workflows like PDF processing, database migrations, deployment procedures, external service integrations, and complex subsystem build processes. These contexts require nuanced instructions that aren't needed in every interaction but are crucial when specific tasks arise.

You're right. Let me think through this.

**What is a skill?**

A skill is a set of on-demand instructions that the agent loads when it decides the task matches. It's:
- **Agent-initiated**: The agent reads the description and decides to load it (vs. slash commands which are user-initiated)
- **Instructions only**: No code execution (vs. custom tools which run TypeScript)
- **On-demand**: Loaded when relevant (vs. AGENTS.md which is always in context)

**When to create a skill vs. alternatives:**

| Use case | Solution |
|----------|----------|
| Always-needed project context (conventions, common commands) | AGENTS.md |
| User wants to trigger a specific prompt | Slash command |
| Need executable code, parameters, return values, TUI | Custom tool |
| Specialized instructions, too big for AGENTS.md, only needed sometimes | Skill |

**Good skill examples:**
- PDF processing workflow with helper scripts in the skill directory
- Deployment procedures for a specific environment
- Instructions for working with a complex internal CLI
- Database migration procedures

**Bad skill examples (use something else):**
- "Always use TypeScript strict mode" → AGENTS.md
- "Review my code" → Slash command
- "Query the database" → Custom tool

**The core idea:** Skills are for chunking specialized knowledge that would bloat AGENTS.md but that the agent should be able to discover and load when relevant.

Does this framing make sense before I update the docs?


check out ../pi-skills/, and then clone https://github.com/anthropics/skills and inspect it


The user wants me to look at actual skill examples to better understand what skills are in practice.

find /Users/badlogic/workspaces/pi-mono/pi-skills -name "SKILL.md" -exec echo "=== {} ===" \; -exec cat {} \;


ls: /Users/badlogic/workspaces/pi-mono/pi-skills/: No such file or directory

Command exited with code 1


find: /Users/badlogic/workspaces/pi-mono/pi-skills: No such file or directory

Command exited with code 1


find /Users/badlogic/workspaces -maxdepth 2 -type d -name "*pi-skills*" 2>/dev/null


It's in a different location - /Users/badlogic/workspaces/pi-skills not pi-mono/pi-skills

find /Users/badlogic/workspaces/pi-skills -name "SKILL.md" -exec echo "=== {} ===" \; -exec cat {} \;


=== /Users/badlogic/workspaces/pi-skills/brave-search/SKILL.md ===
---
name: brave-search
description: Web search and content extraction via Brave Search API. Use for searching documentation, facts, or any web content. Lightweight, no browser required.
---

# Brave Search

Headless web search and content extraction using Brave Search. No browser required.

## Setup

Run once before first use:

```bash
cd {baseDir}/brave-search
npm install
```

## Search

```bash
{baseDir}/search.js "query"                    # Basic search (5 results)
{baseDir}/search.js "query" -n 10              # More results
{baseDir}/search.js "query" --content          # Include page content as markdown
{baseDir}/search.js "query" -n 3 --content     # Combined
```

## Extract Page Content

```bash
{baseDir}/content.js https://example.com/article
```

Fetches a URL and extracts readable content as markdown.

## Output Format

```
--- Result 1 ---
Title: Page Title
Link: https://example.com/page
Snippet: Description from search results
Content: (if --content flag used)
  Markdown content extracted from the page...

--- Result 2 ---
...
```

## When to Use

- Searching for documentation or API references
- Looking up facts or current information
- Fetching content from specific URLs
- Any task requiring web search without interactive browsing
=== /Users/badlogic/workspaces/pi-skills/gccli/SKILL.md ===
---
name: gccli
description: Google Calendar CLI for listing calendars, viewing/creating/updating events, and checking availability.
---

# Google Calendar CLI

Command-line interface for Google Calendar operations.

## Installation

```bash
npm install -g @mariozechner/gccli
```

## Setup

### Google Cloud Console (one-time)

1. [Create a new project](https://console.cloud.google.com/projectcreate) (or select existing)
2. [Enable the Google Calendar API](https://console.cloud.google.com/apis/api/calendar-json.googleapis.com)
3. [Set app name](https://console.cloud.google.com/auth/branding) in OAuth branding
4. [Add test users](https://console.cloud.google.com/auth/audience) (all Gmail addresses you want to use)
5. [Create OAuth client](https://console.cloud.google.com/auth/clients):
   - Click "Create Client"
   - Application type: "Desktop app"
   - Download the JSON file

### Configure gccli

First check if already configured:
```bash
gccli accounts list
```

If no accounts, guide the user through setup:
1. Ask if they have a Google Cloud project with Calendar API enabled
2. If not, walk them through the Google Cloud Console steps above
3. Have them download the OAuth credentials JSON
4. Run: `gccli accounts credentials ~/path/to/credentials.json`
5. Run: `gccli accounts add <email>` (use `--manual` for browserless OAuth)

## Usage

Run `gccli --help` for full command reference.

Common operations:
- `gccli <email> calendars` - List all calendars
- `gccli <email> events <calendarId> [--from <dt>] [--to <dt>]` - List events
- `gccli <email> event <calendarId> <eventId>` - Get event details
- `gccli <email> create <calendarId> --summary <s> --start <dt> --end <dt>` - Create event
- `gccli <email> freebusy <calendarIds> --from <dt> --to <dt>` - Check availability

Use `primary` as calendarId for the main calendar.

## Date/Time Format

- Timed events: `YYYY-MM-DDTHH:MM:SSZ` (UTC) or `YYYY-MM-DDTHH:MM:SS` (local)
- All-day events: `YYYY-MM-DD` with `--all-day` flag

## Data Storage

- `~/.gccli/credentials.json` - OAuth client credentials
- `~/.gccli/accounts.json` - Account tokens
=== /Users/badlogic/workspaces/pi-skills/gmcli/SKILL.md ===
---
name: gmcli
description: Gmail CLI for searching emails, reading threads, sending messages, managing drafts, and handling labels/attachments.
---

# Gmail CLI

Command-line interface for Gmail operations.

## Installation

```bash
npm install -g @mariozechner/gmcli
```

## Setup

### Google Cloud Console (one-time)

1. [Create a new project](https://console.cloud.google.com/projectcreate) (or select existing)
2. [Enable the Gmail API](https://console.cloud.google.com/apis/api/gmail.googleapis.com)
3. [Set app name](https://console.cloud.google.com/auth/branding) in OAuth branding
4. [Add test users](https://console.cloud.google.com/auth/audience) (all Gmail addresses you want to use)
5. [Create OAuth client](https://console.cloud.google.com/auth/clients):
   - Click "Create Client"
   - Application type: "Desktop app"
   - Download the JSON file

### Configure gmcli

First check if already configured:
```bash
gmcli accounts list
```

If no accounts, guide the user through setup:
1. Ask if they have a Google Cloud project with Gmail API enabled
2. If not, walk them through the Google Cloud Console steps above
3. Have them download the OAuth credentials JSON
4. Run: `gmcli accounts credentials ~/path/to/credentials.json`
5. Run: `gmcli accounts add <email>` (use `--manual` for browserless OAuth)

## Usage

Run `gmcli --help` for full command reference.

Common operations:
- `gmcli <email> search "<query>"` - Search emails using Gmail query syntax
- `gmcli <email> thread <threadId>` - Read a thread with all messages
- `gmcli <email> send --to <emails> --subject <s> --body <b>` - Send email
- `gmcli <email> labels list` - List all labels
- `gmcli <email> drafts list` - List drafts

## Data Storage

- `~/.gmcli/credentials.json` - OAuth client credentials
- `~/.gmcli/accounts.json` - Account tokens
- `~/.gmcli/attachments/` - Downloaded attachments
=== /Users/badlogic/workspaces/pi-skills/gdcli/SKILL.md ===
---
name: gdcli
description: Google Drive CLI for listing, searching, uploading, downloading, and sharing files and folders.
---

# Google Drive CLI

Command-line interface for Google Drive operations.

## Installation

```bash
npm install -g @mariozechner/gdcli
```

## Setup

### Google Cloud Console (one-time)

1. [Create a new project](https://console.cloud.google.com/projectcreate) (or select existing)
2. [Enable the Google Drive API](https://console.cloud.google.com/apis/api/drive.googleapis.com)
3. [Set app name](https://console.cloud.google.com/auth/branding) in OAuth branding
4. [Add test users](https://console.cloud.google.com/auth/audience) (all Gmail addresses you want to use)
5. [Create OAuth client](https://console.cloud.google.com/auth/clients):
   - Click "Create Client"
   - Application type: "Desktop app"
   - Download the JSON file

### Configure gdcli

First check if already configured:
```bash
gdcli accounts list
```

If no accounts, guide the user through setup:
1. Ask if they have a Google Cloud project with Drive API enabled
2. If not, walk them through the Google Cloud Console steps above
3. Have them download the OAuth credentials JSON
4. Run: `gdcli accounts credentials ~/path/to/credentials.json`
5. Run: `gdcli accounts add <email>` (use `--manual` for browserless OAuth)

## Usage

Run `gdcli --help` for full command reference.

Common operations:
- `gdcli <email> ls [folderId]` - List files/folders
- `gdcli <email> ls --query "<query>"` - List with Drive query filter
- `gdcli <email> search "<text>"` - Full-text content search
- `gdcli <email> download <fileId> [destPath]` - Download a file
- `gdcli <email> upload <localPath> [--folder <folderId>]` - Upload a file
- `gdcli <email> mkdir <name>` - Create a folder
- `gdcli <email> share <fileId> --anyone` - Share publicly

## Search

**Two different commands:**
- `search "<text>"` - Searches inside file contents (fullText)
- `ls --query "<query>"` - Filters by metadata (name, type, date, etc.)

**Use `ls --query` for filename searches!**

## Query Syntax (for ls --query)

Format: `field operator value`. Combine with `and`/`or`, group with `()`.

**Operators:** `=`, `!=`, `contains`, `<`, `>`, `<=`, `>=`

**Examples:**
```bash
# By filename
ls --query "name = 'report.pdf'"           # exact match
ls --query "name contains 'IMG'"           # prefix match

# By type
ls --query "mimeType = 'application/pdf'"
ls --query "mimeType contains 'image/'"
ls --query "mimeType = 'application/vnd.google-apps.folder'"  # folders

# By date
ls --query "modifiedTime > '2024-01-01'"

# By owner/sharing
ls --query "'me' in owners"
ls --query "sharedWithMe"

# Exclude trash
ls --query "trashed = false"

# Combined
ls --query "name contains 'report' and mimeType = 'application/pdf'"
```

Ref: https://developers.google.com/drive/api/guides/ref-search-terms

## Data Storage

- `~/.gdcli/credentials.json` - OAuth client credentials
- `~/.gdcli/accounts.json` - Account tokens
- `~/.gdcli/downloads/` - Default download location
=== /Users/badlogic/workspaces/pi-skills/vscode/SKILL.md ===
---
name: vscode
description: VS Code integration for viewing diffs and comparing files. Use when showing file differences to the user.
---

# VS Code CLI Tools

Tools for integrating with VS Code, primarily for viewing diffs.

## Requirements

VS Code must be installed with the `code` CLI available in PATH.

## Opening a Diff

Compare two files side by side in VS Code:

```bash
code -d <file1> <file2>
```

## Git Diffs in VS Code

### Simple Approach (no config needed)

Extract the old version to a temp file, then diff:

```bash
# Compare with previous commit
git show HEAD~1:path/to/file > /tmp/old && code -d /tmp/old path/to/file

# Compare with specific commit
git show abc123:path/to/file > /tmp/old && code -d /tmp/old path/to/file

# Compare staged version with working tree
git show :path/to/file > /tmp/staged && code -d /tmp/staged path/to/file
```

### Gotchas

- File must exist and have changes between the compared revisions
- Use `git log --oneline -5 -- path/to/file` to verify file has history before diffing

## When to Use

- Showing the user what changed in a file
- Comparing two versions of code
- Reviewing git changes visually
=== /Users/badlogic/workspaces/pi-skills/transcribe/SKILL.md ===
---
name: transcribe
description: Speech-to-text transcription using Groq Whisper API. Supports m4a, mp3, wav, ogg, flac, webm.
---

# Transcribe

Speech-to-text using Groq Whisper API.

## Setup

The script needs `GROQ_API_KEY` environment variable. Check if already set:
```bash
echo $GROQ_API_KEY
```

If not set, guide the user through setup:
1. Ask if they have a Groq API key
2. If not, have them sign up at https://console.groq.com/ and create an API key
3. Have them add to their shell profile (~/.zshrc or ~/.bashrc):
   ```bash
   export GROQ_API_KEY="<their-api-key>"
   ```
4. Then run `source ~/.zshrc` (or restart terminal)

## Usage

```bash
{baseDir}/transcribe.sh <audio-file>
```

## Supported Formats

- m4a, mp3, wav, ogg, flac, webm
- Max file size: 25MB

## Output

Returns plain text transcription with punctuation and proper capitalization to stdout.
=== /Users/badlogic/workspaces/pi-skills/youtube-transcript/SKILL.md ===
---
name: youtube-transcript
description: Fetch transcripts from YouTube videos for summarization and analysis.
---

# YouTube Transcript

Fetch transcripts from YouTube videos.

## Setup

```bash
cd {baseDir}
npm install
```

## Usage

```bash
{baseDir}/transcript.js <video-id-or-url>
```

Accepts video ID or full URL:
- `EBw7gsDPAYQ`
- `https://www.youtube.com/watch?v=EBw7gsDPAYQ`
- `https://youtu.be/EBw7gsDPAYQ`

## Output

Timestamped transcript entries:

```
[0:00] All right. So, I got this UniFi Theta
[0:15] I took the camera out, painted it
[1:23] And here's the final result
```

## Notes

- Requires the video to have captions/transcripts available
- Works with auto-generated and manual transcripts
=== /Users/badlogic/workspaces/pi-skills/browser-tools/SKILL.md ===
---
name: browser-tools
description: Interactive browser automation via Chrome DevTools Protocol. Use when you need to interact with web pages, test frontends, or when user interaction with a visible browser is required.
---

# Browser Tools

Chrome DevTools Protocol tools for agent-assisted web automation. These tools connect to Chrome running on `:9222` with remote debugging enabled.

## Setup

Run once before first use:

```bash
cd {baseDir}/browser-tools
npm install
```

## Start Chrome

```bash
{baseDir}/browser-start.js              # Fresh profile
{baseDir}/browser-start.js --profile    # Copy user's profile (cookies, logins)
```

Launch Chrome with remote debugging on `:9222`. Use `--profile` to preserve user's authentication state.

## Navigate

```bash
{baseDir}/browser-nav.js https://example.com
{baseDir}/browser-nav.js https://example.com --new
```

Navigate to URLs. Use `--new` flag to open in a new tab instead of reusing current tab.

## Evaluate JavaScript

```bash
{baseDir}/browser-eval.js 'document.title'
{baseDir}/browser-eval.js 'document.querySelectorAll("a").length'
```

Execute JavaScript in the active tab. Code runs in async context. Use this to extract data, inspect page state, or perform DOM operations programmatically.

## Screenshot

```bash
{baseDir}/browser-screenshot.js
```

Capture current viewport and return temporary file path. Use this to visually inspect page state or verify UI changes.

## Pick Elements

```bash
{baseDir}/browser-pick.js "Click the submit button"
```

**IMPORTANT**: Use this tool when the user wants to select specific DOM elements on the page. This launches an interactive picker that lets the user click elements to select them. The user can select multiple elements (Cmd/Ctrl+Click) and press Enter when done. The tool returns CSS selectors for the selected elements.

Common use cases:
- User says "I want to click that button" → Use this tool to let them select it
- User says "extract data from these items" → Use this tool to let them select the elements
- When you need specific selectors but the page structure is complex or ambiguous

## Cookies

```bash
{baseDir}/browser-cookies.js
```

Display all cookies for the current tab including domain, path, httpOnly, and secure flags. Use this to debug authentication issues or inspect session state.

## Extract Page Content

```bash
{baseDir}/browser-content.js https://example.com
```

Navigate to a URL and extract readable content as markdown. Uses Mozilla Readability for article extraction and Turndown for HTML-to-markdown conversion. Works on pages with JavaScript content (waits for page to load).

## When to Use

- Testing frontend code in a real browser
- Interacting with pages that require JavaScript
- When user needs to visually see or interact with a page
- Debugging authentication or session issues
- Scraping dynamic content that requires JS execution


Now let me clone the Anthropic skills repo and look at it.

Now let me clone the Anthropic skills repo:

cd /tmp && rm -rf skills && git clone https://github.com/anthropics/skills.git 2>&1


Cloning into 'skills'...


Let me inspect the Anthropic skills repo.

find /tmp/skills -name "*.md" -exec echo "=== {} ===" \; -exec cat {} \;