# 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 `@earendil-works/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 <pattern>`: Model pattern or ID (supports `provider/id` and optional `:<thinking>`)
- `--name <name>` / `-n <name>`: Set the session display name at startup
- `--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`. `bash_execution_update` events also include the `id` of their originating `bash` command.

### Framing

RPC mode uses strict JSONL semantics with LF (`\n`) as the only record delimiter.

This matters for clients:
- Split records on `\n` only
- Accept optional `\r\n` input by stripping a trailing `\r`
- Do not use generic line readers that treat Unicode separators as newlines

In particular, Node `readline` is not protocol-compliant for RPC mode because it also splits on `U+2028` and `U+2029`, which are valid inside JSON strings.

## Commands

### Prompting

#### prompt

Send a user prompt to the agent. The command response is emitted after the prompt is accepted, queued, or handled. Events continue streaming asynchronously after acceptance.

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

With images:
```json
{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}
```

**During streaming**: If the agent is already streaming, you must specify `streamingBehavior` to queue the message:

```json
{"type": "prompt", "message": "New instruction", "streamingBehavior": "steer"}
```

- `"steer"`: Queue the message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.
- `"followUp"`: Wait until the agent finishes. Message is delivered only when agent stops.

If the agent is streaming and no `streamingBehavior` is specified, the command returns an error.

**During compaction**: If the agent is compacting, the command is rejected unless `queueWhileCompacting` is set:

```json
{"type": "prompt", "message": "Follow-up", "queueWhileCompacting": true}
```

With `queueWhileCompacting: true` the prompt is accepted into a buffer (`success: true` is returned immediately) and auto-replayed in order after `compaction_end`. If compaction aborts or errors, the buffer is discarded.

**Extension commands**: If the message is an extension command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`.

**Input expansion**: Skill commands (`/skill:name`) and prompt templates (`/template`) are expanded before sending/queueing.

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

`success: true` means the prompt was accepted, queued, or handled immediately. `success: false` means the prompt was rejected before acceptance. Failures after acceptance are reported through the normal event and message stream, not as a second `response` for the same request id.

The `images` field is optional. Each image uses `ImageContent` format: `{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}`.

#### steer

Queue a steering message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).

```json
{"type": "steer", "message": "Stop and do this instead"}
```

With images:
```json
{"type": "steer", "message": "Look at this instead", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}
```

The `images` field is optional. Each image uses `ImageContent` format (same as `prompt`).

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

See [set_steering_mode](#set_steering_mode) for controlling how steering messages are processed.

#### follow_up

Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).

```json
{"type": "follow_up", "message": "After you're done, also do this"}
```

With images:
```json
{"type": "follow_up", "message": "Also check this image", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}
```

The `images` field is optional. Each image uses `ImageContent` format (same as `prompt`).

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

See [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up messages are processed.

#### abort

Abort the current agent operation.

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

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

#### new_session

Start a fresh session. Can be cancelled by a `session_before_switch` extension event handler.

```json
{"type": "new_session"}
```

With optional parent session tracking:
```json
{"type": "new_session", "parentSession": "/path/to/parent-session.jsonl"}
```

Response:
```json
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}
```

If an extension cancelled:
```json
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": 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,
    "isRetrying": false,
    "steeringMode": "all",
    "followUpMode": "one-at-a-time",
    "sessionFile": "/path/to/session.jsonl",
    "sessionId": "abc123",
    "sessionName": "my-feature-work",
    "autoCompactionEnabled": true,
    "messageCount": 5,
    "pendingMessageCount": 0
  }
}
```

The `model` field is a full [Model](#model) object or `null`. `isRetrying` is true while an auto-retry is in flight, including the backoff sleep between attempts (`isStreaming` can be false then). The `sessionName` field is the display name set via `set_session_name`, or omitted if not set.

#### 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 `AgentMessage` 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"}
```

Optional `direction` (defaults to `"forward"`):

```json
{"type": "cycle_model", "direction": "backward"}
```

`direction` is `"forward"` or `"backward"`.

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

Optional `refresh` flag (TUI parity) re-fetches model catalogs before listing; a small timeout applies and the current snapshot is used if the network refresh fails:

```json
{"type": "get_available_models", "refresh": true}
```

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 Modes

#### set_steering_mode

Control how steering messages (from `steer`) are delivered.

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

Modes:
- `"all"`: Deliver all steering messages after the current assistant turn finishes executing its tool calls
- `"one-at-a-time"`: Deliver one steering message per completed assistant turn (default)

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

#### set_follow_up_mode

Control how follow-up messages (from `follow_up`) are delivered.

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

Modes:
- `"all"`: Deliver all follow-up messages when agent finishes
- `"one-at-a-time"`: Deliver one follow-up message per agent completion (default)

Response:
```json
{"type": "response", "command": "set_follow_up_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": {
    "summary": "Summary of conversation...",
    "firstKeptEntryId": "abc123",
    "tokensBefore": 150000,
    "estimatedTokensAfter": 32000,
    "details": {}
  }
}
```

`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count.

#### 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. Output streams as `bash_execution_update` events while the command runs; the response contains the final result.

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

Include an `id` to associate streamed `bash_execution_update` events with this command.

Response:
```json
{
  "id": "req-1",
  "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.

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

#### 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, cost statistics, and current context window usage.

```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,
    "contextUsage": {
      "tokens": 60000,
      "contextWindow": 200000,
      "percent": 30
    }
  }
}
```

`tokens` contains assistant usage totals for the current session state. `contextUsage` contains the actual current context-window estimate used for compaction and footer display.

`contextUsage` is omitted when no model or context window is available. `contextUsage.tokens` and `contextUsage.percent` are `null` immediately after compaction until a fresh post-compaction assistant response provides valid usage data.

#### export_html

Export session to an HTML file.

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

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

Optional `themeName` selects the export theme (otherwise the current theme is used):
```json
{"type": "export_html", "outputPath": "/tmp/session.html", "themeName": "dark"}
```

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

#### switch_session

Load a different session file. Can be cancelled by a `session_before_switch` extension event handler.

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

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

If an extension cancelled the switch:
```json
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
```

#### fork

Create a new fork from a previous user message on the active branch. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from.

```json
{"type": "fork", "entryId": "abc123"}
```

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

If an extension cancelled the fork:
```json
{
  "type": "response",
  "command": "fork",
  "success": true,
  "data": {"text": "The original prompt text...", "cancelled": true}
}
```

#### clone

Duplicate the current active branch into a new session at the current position. Can be cancelled by a `session_before_fork` extension event handler.

```json
{"type": "clone"}
```

Response:
```json
{
  "type": "response",
  "command": "clone",
  "success": true,
  "data": {"cancelled": false}
}
```

If an extension cancelled the clone:
```json
{
  "type": "response",
  "command": "clone",
  "success": true,
  "data": {"cancelled": true}
}
```

#### get_fork_messages

Get user messages available for forking.

```json
{"type": "get_fork_messages"}
```

Response:
```json
{
  "type": "response",
  "command": "get_fork_messages",
  "success": true,
  "data": {
    "messages": [
      {"entryId": "abc123", "text": "First prompt..."},
      {"entryId": "def456", "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.

#### set_session_name

Set a display name for the current session. The name appears in session listings and helps identify sessions.

```json
{"type": "set_session_name", "name": "my-feature-work"}
```

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

The current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name <name>` or `-n <name>` to the `pi --mode rpc` process.

#### navigate_tree

Navigate the session tree to a different entry (the TUI tree selector equivalent). Rejects while the agent is streaming; emits a `session_tree` event on success.

```json
{"type": "navigate_tree", "targetId": "abc123"}
```

Optional fields:
- `summarize`: summarize the branch between the old leaf and the target before navigating.
- `customInstructions`: custom summary instructions (with `summarize`).
- `replaceInstructions`: replace (rather than append to) the default summary instructions.
- `label`: attach the given label to the target entry.

Response:
```json
{
  "type": "response",
  "command": "navigate_tree",
  "success": true,
  "data": {
    "editorText": "...",
    "cancelled": false,
    "aborted": false,
    "summaryEntry": null
  }
}
```

`editorText` is present when the target is a user message (it becomes the editor prefill). `aborted` is set when a `session_before_navigate`-style extension handler cancels the navigation. Fails with `Entry <id> not found` for unknown targets and `No model available for summarization` when `summarize` is requested but no model is configured.

#### list_sessions

List sessions. `scope` defaults to `"current"` (sessions in the current cwd/session dir); `"all"` searches the full session store.

```json
{"type": "list_sessions"}
{"type": "list_sessions", "scope": "all"}
```

Response:
```json
{
  "type": "response",
  "command": "list_sessions",
  "success": true,
  "data": {
    "sessions": [
      {
        "path": "/path/to/session.jsonl",
        "id": "abc123",
        "cwd": "/path/to/project",
        "name": "my-feature-work",
        "parentSessionPath": null,
        "created": "2026-09-01T00:00:00.000Z",
        "modified": "2026-09-01T01:00:00.000Z",
        "messageCount": 42,
        "firstMessage": "First user prompt..."
      }
    ]
  }
}
```

`name` is omitted when unset. `created`/`modified` are ISO strings; sessions with invalid timestamps (e.g. hand-written JSONL) are reported as epoch (`1970-01-01T00:00:00.000Z`).

#### rename_session

Rename a session by writing the name into its JSONL header. The file must exist; an empty name is rejected.

```json
{"type": "rename_session", "path": "/path/to/session.jsonl", "name": "my-feature-work"}
```

Response: `{"command": "rename_session", "success": true, "data": {"path": "/path/to/session.jsonl", "name": "my-feature-work"}}`

#### delete_session

Delete a session. Moves it to the operating system trash when available, else unlinks the file directly. Refuses to delete the currently active session.

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

Response: `{"command": "delete_session", "success": true, "data": {"path": "/path/to/session.jsonl", "method": "trash"}}`

`method` is `"trash"` or `"unlink"`.

#### set_session_models

Scope the session to a subset of models (mirrors the TUI session-only model scoping). Ephemeral: never writes settings files and does not affect the persistent scoped-model configuration. Pass an empty array to clear the session scope.

```json
{"type": "set_session_models", "enabled": ["anthropic/claude-sonnet-4-20250514", "anthropic/*"]}
```

`enabled` entries are concrete `provider/id` identifiers or scope/wildcard patterns (e.g. `"anthropic/*"`). `reorder` (a reordered pattern list) rebuilds the scope without changing which models are enabled. At least one of the two must be present.

Response:
```json
{
  "type": "response",
  "command": "set_session_models",
  "success": true,
  "data": {
    "enabled": ["anthropic/claude-sonnet-4-20250514"],
    "models": [{...}]
  }
}
```

`enabled` is the resolved list of `provider/id` strings; `models` is the matching array of [Model](#model) objects.

#### share_gist

Export the session to HTML and share it as a private GitHub gist (requires the GitHub CLI authenticated via `gh auth login`).

```json
{"type": "share_gist"}
```

Optional `themeName` selects the export theme:
```json
{"type": "share_gist", "themeName": "dark"}
```

Response: `{"command": "share_gist", "success": true, "data": {"url": "https://github.com/user/...", "gistUrl": "https://gist.github.com/..."}}`

### Subagent observability (Desktop / headless clients)

`subagent_status` is a dedicated **read-only** command. It does not invoke an LLM, the `subagent` tool, a slash command, or the generic internal RPC/event bus. It reads the loaded extension's current native foreground controls/history and tracker-fed async/fleet state. No arbitrary user paths are scanned. Spawn/manage/steer/stop/resume are **not** exposed by this stdio contract.

```json
{"type":"subagent_status","id":"status-1","version":1}
{"type":"response","id":"status-1","command":"subagent_status","success":true,"data":{"version":1,"available":true,"capabilities":{"snapshot":true,"events":true},"sessionId":"host-session-id","epoch":"bind-uuid","revision":2,"fleet":{"version":1,"entries":[{"key":"fleet-1","agent":"worker","startedAt":1733234500000,"tokens":{"input":12,"output":3,"total":15}}],"totalActive":1,"topLevelAsyncCapacity":{"used":1,"limit":3},"omitted":0},"runs":[{"key":"node-opaque","runId":"run-1","status":"running","startedAt":1733234500000,"children":[{"key":"node-child-opaque","agent":"worker","status":"running","currentTool":"read","tokens":{"input":12,"output":3,"total":15},"children":[],"omittedChildren":0}],"omittedChildren":0}],"omitted":{"runs":0,"children":0}}}
```

The exact machine-readable JSON Schema for commands, success responses, events, snapshots, fleet and recursive nodes is [rpc-subagent.schema.json](rpc-subagent.schema.json); TypeScript definitions are in [`src/core/subagent-rpc.ts`](../src/core/subagent-rpc.ts). `version:1` is required; missing/unknown versions, extra command fields, non-string/overlong IDs and malformed backend projections fail with normal `success:false` responses. Optional `id` is at most 128 characters without CR/LF. Unknown future wire fields should be ignored by clients.

Missing extension (also after disposal) is explicit, **not** an empty healthy fleet:

```json
{"type":"response","command":"subagent_status","success":true,"data":{"version":1,"available":false,"capabilities":{"snapshot":true,"events":false},"sessionId":"host-session-id","epoch":"bind-uuid","revision":1,"fleet":{"version":1,"entries":[],"totalActive":0,"topLevelAsyncCapacity":{"used":0,"limit":0},"omitted":0},"runs":[],"omitted":{"runs":0,"children":0}}}
{"type":"response","command":"subagent_status","success":false,"error":"Unsupported subagent_status version; expected 1"}
```

An initial `subagent_event` is emitted after extension binding, including when unavailable, and again for every session switch/rebind/reload. Each event is a **full replacement snapshot**, identical to the authoritative status projection at its revision, not a patch:

```json
{"type":"subagent_event","version":1,"sessionId":"host-session-id","epoch":"bind-uuid","revision":1,"snapshot":{"version":1,"available":true,"capabilities":{"snapshot":true,"events":true},"sessionId":"host-session-id","epoch":"bind-uuid","revision":1,"fleet":{"version":1,"entries":[],"totalActive":0,"topLevelAsyncCapacity":{"used":0,"limit":0},"omitted":0},"runs":[],"omitted":{"runs":0,"children":0}}}
```

Scope and recovery:
- `sessionId` is the **bare host parent SDK session ID**, never an artifact path or broker endpoint. Internally, native run ownership can use a session-file path; the adapter filters on that native identity without exposing it as a node identity.
- `epoch` is a fresh host-binding UUID shared with Intercom's nested `scope`. Rebinding the same session/reloading rotates it. Revision starts at 1 after binding and increases monotonically on projection changes; querying unchanged status does not advance it. A status query may publish a fresh changed snapshot immediately. Native progress changes are sampled/coalesced at 250 ms while subscribed; identical snapshots emit nothing.
- Desktop should retain the current `(sessionId, epoch)` from the current binding/status, reject obsolete scopes and revisions, and replace the whole projection. Reconnect by requesting status; events are not a durable log. Late callbacks from disposed bindings are ignored and subscriptions/timers are released. Parent `agent_end` is **not** child completion: async work remains active until its own runtime lifecycle changes.
- `available:true` means the native observation backend is loaded, not that any child, model, or task is healthy. `capabilities.snapshot` is always true; `events` is true only with the backend. Unavailable snapshots contain zero counters as unavailable placeholders, not evidence of an idle fleet.

Node semantics and bounds:
- `runs` holds bounded native foreground controls, retained foreground child rows, and current/recent async records. It is a runtime display window, **not a durable run ledger**. Foreground retained child rows have the parent `runId`; async records use their native async run ID. A `workflowId` is only present when native workflow ownership is known. `key` is opaque; reconcile keys only inside the current epoch, never parse them or use paths as IDs. Fleet keys are a separate active-child display namespace; do not join fleet keys to run keys.
- `status` is the native lifecycle: `queued|pending|running|complete|completed|failed|partial|paused|stopped|rejected|detached`. Optional `activity` is separate: `waiting_supervisor` comes only from an actual registered pending native supervisor request; `needs_attention` comes only from native control activity. Clearing a pending request removes the wait hint. Do not infer activity from parent streaming, elapsed time, output text or tool names.
- Optional numeric `startedAt`, `updatedAt`, `endedAt` are native millisecond timestamps. `progress` is a bounded latest-output/description projection (256 characters), `currentTool` is at most 96 characters, `agent` 96, `error` 256, `reason` 128. No arguments, transcripts, arbitrary artifacts, or paths-as-identity are included; display strings can naturally mention paths and are not a secret-redaction API.
- `tokens`, when genuinely present, is `{input,output,total,window?,windowPeak?}` with nonnegative safe integers. Do not invent missing counters. Fleet retains the existing DTO's zero-counter fallback for absent usage; run/node tokens are omitted if unknown.
- Optional `outcome:{status,success?,exitCode?}` is only copied from an explicit native `execution` projection. A lifecycle `complete`/`completed`, terminal timestamp, exit code in other metadata, or output presence does **not** establish task success. `processTerminal:{state,observedAt?,reason?}` independently copies native runner proof (`pending|observed|unknown|not-started`); never infer it from task outcome or lifecycle. Both may be absent, and a failed task may have observed process closure.
- Fleet has at most 16 entries and 256 candidate records. `totalActive` counts native active children before the display bound; `omitted = totalActive - entries.length`, including malformed display rows. `topLevelAsyncCapacity.limit:0` means that native opt-in cap is disabled.
- At most 32 run roots, 64 total run/child nodes, 16 immediate children per node, and child depth 4 are emitted. The serialized projection reserves 1 KiB inside a 64 KiB budget for wire scope/envelope. Overflow/malformed records are omitted. `omitted.runs` counts excluded root candidates; every emitted node has `omittedChildren`, counting excluded **immediate child candidates** (not recursively enumerating hidden descendants). `omitted.children` is the sum of these counts over emitted nodes. Entire roots can be removed to meet the byte budget. These counts describe this projection window only, not expired native history or undiscovered artifacts.

TypeScript: `RpcClient.subagentStatus(): Promise<SubagentRpcSnapshot>` and `RpcEvent`/`SubagentRpcEvent` are exported with `SubagentRpcCommand`, `SubagentRpcResponse`, `SubagentRpcNode`, `SubagentRpcFleet`, `SubagentRpcTokens`, `SubagentRpcLifecycle`, and `RpcObservationScope` from `@selesai/code`. The dedicated internal source bridge is not an external extension-bus RPC invocation API.

### Release readiness (Desktop / headless clients)

`release_readiness` answers one question authoritatively: **is it safe to stop this process right now, and if not, why?** A host that spawns one `--mode rpc` child per session uses it to decide when an idle child may be released (gracefully stopped) without losing background subagent runs, intercom asks, armed session-only schedules, queued prompts, open extension dialogs or retrying turns. It is read-only and never invokes a model. Call it from events (navigate away, turn end, background result delivered), not on a timer.

```json
{"type":"release_readiness","id":"r-1","version":1}
{"type":"response","id":"r-1","command":"release_readiness","success":true,"data":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid","safe":false,"contributors":["subagents","intercom","scheduler"],"blockers":[{"source":"subagents","kind":"async_run","id":"run-1","detail":"Async single running: worker","since":1733234500000}]}}
{"type":"response","command":"release_readiness","success":false,"error":"Unsupported release_readiness version; expected 1"}
```

- `version` must be `1`; unknown keys and an `id` longer than 128 characters or containing line breaks are rejected with a failure response. After a session switch/reload the previous binding answers `"Release readiness session ended"`.
- `safe` is `true` **only** when `blockers` is empty. Treat any failure response as "not safe".
- `sessionId` and `epoch` are the same scope as `subagent_status`/Intercom events. Drop answers whose `(sessionId, epoch)` is obsolete; the epoch rotates on `new_session`, `switch_session`, fork/clone, import and reload.
- `contributors` lists the extension contributors that answered (`subagents`, `intercom`, `scheduler`). **`safe: true` with an empty `contributors` list means no extension reported**, e.g. the extensions are not loaded. Core blockers are always evaluated. A host that requires extension coverage should check the list.
- **Fail closed.** A contributor that throws, returns malformed blockers, or fails while reconciling a run yields a `contributor_error` blocker instead of silently answering safe. Over-long contributions are truncated with a `contributor_error` marker (64 blockers per contributor, 256 total).
- A dead background runner is **not** a blocker: it is repaired into a failed result first and then reported as `undelivered_result` until that result is delivered to the session.

Blocker fields: `source` (`core`, `subagents`, `intercom`, `scheduler`, or another extension name), `kind` (stable token), optional `id`, human-readable `detail`, optional `since` (epoch ms; for `armed_schedule` it is the next run time).

| source | kind | Reported when |
|---|---|---|
| core | `streaming` | an agent run (or its post-run continuation) is active |
| core | `compacting` | compaction or branch summarization is running |
| core | `retrying` | an auto-retry is in flight (also `isRetrying` in `get_state`) |
| core | `queued_messages` | steering/follow-up messages are queued |
| core | `pending_tool_calls` | tool calls are in flight |
| core | `pending_bash` | a `bash` command is running or its output awaits flushing |
| core | `extension_dialog` | an extension UI request awaits an `extension_ui_response` |
| core | `compaction_prompt_queue` | prompts are buffered until compaction ends |
| core | `prompt_in_flight` | a `prompt` command has not finished processing |
| core | `session_transition` | a session switch/new/fork/reload/rebind is in progress |
| core | `releasing` | a `release` command has been committed and the process is shutting down (see [`release`](#release-atomic-release-if-safe)) |
| subagents | `async_run` | a background run is queued/running, has live nested descendants, or a retained nested route may still be live (`id` = run id, `since` = start) |
| subagents | `foreground_run` | a foreground subagent run is in progress |
| subagents | `undelivered_result` | a repaired/finished run's result, a queued completion notification, or a persisted result owned by this process has not been delivered |
| intercom | `inbound_ask` | an ask is awaiting our reply (`id` = message id) |
| intercom | `outbound_ask` | our blocking ask awaits a reply |
| intercom | `outbound_message` | an outbox send from another extension has not settled |
| scheduler | `armed_schedule` | a **session-only** schedule holds an armed timer in this process (it would be lost on exit). Project schedules persist on disk and never block. An armed schedule whose record cannot be read is reported too (fail closed) |
| scheduler | `scheduled_run_pending` | a scheduled run (of any schedule kind) is still awaiting completion |
| any | `contributor_error` | a contributor failed (fail closed) |

Extension contract (internal, not an external RPC API): the host emits `release:readiness:v1` on the session event bus with `{version:1, sessionId, contribute(name, blockers)}`. Listeners call `contribute` synchronously, many contributors are accepted, and listeners must ignore requests whose `sessionId` is not the session they host (`answerReleaseReadiness` from `@selesai/code` implements the gate and the fail-closed try/catch).

TypeScript: `RpcClient.releaseReadiness(): Promise<ReleaseReadiness>`; `ReleaseReadinessCommand`, `ReleaseReadinessResponse`, `ReleaseReadiness` and `ReleaseBlocker` are exported types.

#### `release`: atomic release-if-safe

Asking `release_readiness` and then closing stdin is racy: stdin EOF shuts the process down immediately, so work that starts between the "safe" answer and the EOF (a schedule timer fires, an intercom message triggers a turn, a background result arrives) is lost. `release` closes that window by making the check and the commitment one step inside the process.

```json
{"type":"release","id":"r-2","version":1}
{"type":"response","id":"r-2","command":"release","success":true,"data":{"released":true,"readiness":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid","safe":true,"contributors":["subagents","intercom","scheduler"],"blockers":[]}}}
{"type":"response","id":"r-3","command":"release","success":true,"data":{"released":false,"readiness":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid","safe":false,"contributors":["subagents","intercom","scheduler"],"blockers":[{"source":"core","kind":"pending_bash","detail":"Bash command running"}]}}}
```

- Request fields and validation are identical to `release_readiness` (`version` must be `1`, no unknown keys, `id` at most 128 characters without line breaks); a malformed request gets a failure response and nothing is committed.
- The process computes readiness exactly as `release_readiness` does (core blockers plus every extension contributor) and, in the same synchronous step, decides:
  - **Releasable** = `safe` **and** at least one contributor answered. The response is `released: true`. The process then rejects every later command with `success:false, error:"Session is releasing"` and, once the response is flushed, takes the normal graceful shutdown path: extensions receive `session_shutdown`, **nothing is aborted**, and the exit code is `0`. Because releasable implies idle, there is no in-flight turn to lose. Stdin EOF after a committed release is harmless.
  - **Not releasable**: `released: false` with the full `readiness` (the blockers). The process keeps running normally and nothing changed.
- **Nobody answered is not released.** `safe: true` with an empty `contributors` list (extensions not loaded) yields `released: false`, because the process cannot prove that no background work exists. The `readiness` in the response shows `safe: true, contributors: []`; hosts that want to stop such a process anyway must do so themselves.
- After a committed release: `release` is answered idempotently with `released: true` (and the committed `readiness`); `release_readiness` answers `safe: false` with a core `releasing` blocker; every other command fails with `Session is releasing`. `extension_ui_response` lines are still routed.
- Residual window: work that bypasses the RPC command loop (extension timers such as session-only schedules, intercom-triggered turns, background results) is not refused by the flag; between the commit and the process exit (the stdout flush plus extension disposal, typically milliseconds) such work would be cut off by the shutdown. This is the same disposal that stdin EOF triggers, but without the unbounded gap between the readiness answer and the EOF.
- Prefer `release` over `release_readiness` + closing stdin. For CLIs that predate it (the `release` command fails with `Unknown command: release`), fall back to `release_readiness` and, only when `safe` with a non-empty `contributors`, close stdin.

TypeScript: `RpcClient.release(): Promise<ReleaseResult>`; `ReleaseCommand`, `ReleaseResponse` and `ReleaseResult` are exported types.

#### `stop_all_background`: kill switch for "Quit anyway"

`release` and `release_readiness` never stop work. `stop_all_background` is the opposite: **one destructive command that leaves nothing of this session running or armed.** It is intended only for an explicit user decision such as a "Quit anyway" button after a quit warning built from `release_readiness` blockers; never call it from automatic release, idle, cap or navigation logic. Afterwards the host exits the child gently (close stdin) or kills it.

```json
{"type":"stop_all_background","id":"k-1","version":1}
{"type":"response","id":"k-1","command":"stop_all_background","success":true,"data":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid","stopped":[{"source":"core","kind":"turn","detail":"Aborted in-flight agent turn","ok":true},{"source":"scheduler","kind":"schedule","id":"nightly","detail":"Paused session-only schedule nightly and cleared its timer (schedule.resume re-arms it)","ok":true},{"source":"subagents","kind":"async_run","id":"run-1","detail":"Stopped Async single (worker) run-1","ok":true},{"source":"intercom","kind":"inbound_ask","id":"m-7","detail":"cannot cancel inbound: ask from planner is awaiting our reply and keeps waiting","ok":false,"error":"cannot cancel inbound"}],"remaining":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid","safe":false,"contributors":["subagents","intercom","scheduler"],"blockers":[{"source":"intercom","kind":"inbound_ask","id":"m-7","detail":"Ask from planner is awaiting our reply"}]}}}
{"type":"response","command":"stop_all_background","success":false,"error":"Unsupported stop_all_background version; expected 1"}
```

- Request fields and validation are identical to `release_readiness` and `release` (`version` must be `1`, no unknown keys, `id` at most 128 characters without line breaks). `success:false` is returned **only** for a malformed request, while the session is releasing (`error:"Session is releasing"`, like every other command after a committed `release`), or when the binding was replaced during the stop. Individual failures never fail the command: they are `ok:false` items.
- `stopped` lists everything that was running, armed or pending when the command arrived, one item per thing: `{source, kind, id?, detail, ok, error?}`. `ok:true` means it is no longer running/armed/pending; `ok:false` carries a short `error` (`timeout`, `cannot cancel inbound`, `foreign_session`, or the failure text). An idle session yields `stopped: []`. Items are ordered by contributor: `core`, then extensions in registration order.
- `remaining` is the same `ReleaseReadiness` that `release_readiness` returns, computed **after** the stop: show it to explain what could not be stopped. Note that a run which was just stopped usually still appears as `undelivered_result` (its `stopped` result has not been delivered to the parent); that is not running work.
- **Idempotent.** A second call finds nothing left to stop: `stopped` only repeats things that cannot be stopped (inbound asks, runs of another session), usually `[]`.
- **Bounded.** The core abort and every extension's stop start together (synchronously inside the call) and are awaited under ONE total budget of about 5 seconds. A part that has not finished by then is reported `ok:false, error:"timeout"` and the response is sent anyway; its stop request stays in effect.

What is stopped:

| Source | What | How |
| --- | --- | --- |
| core | the in-flight agent turn, auto-retry, compaction and branch summarization | the same path as the `abort` command (`abortBranchSummary`, `abortCompaction`, `session.abort()`), plus: a running `bash` command is killed, and queued steer/follow-up messages and prompts buffered until compaction ends are dropped first so nothing restarts the turn after the abort |
| core / subagents | **foreground** subagent runs | they belong to the turn: the abort signal kills their child processes. The subagents handler only waits (within the budget) for them to leave and reports each as `foreground_run` |
| subagents | every live **async / detached** run of this session (`async_run`), runs a scheduled run is still awaiting (`scheduled_run`, the `scheduled_run_pending` blockers) and live nested descendants (`nested_run`) | the same machinery as the subagents RPC `stop` method: a stop request file is written to the run's control inbox (`<run dir>/control/stop-requests/`). The detached runner consumes it by itself, ends with status `stopped`, and exits; nothing depends on the CLI process staying alive, and the request is written before the call's first asynchronous step, so exiting right after the response is safe. Each item is `ok:true` once the runner's status is terminal |
| scheduler | **session-only** schedules armed in this process | **paused** (persisted) and their timers cleared, so they neither fire now nor on the next launch (`catchUp: "latest"` cannot revive a paused schedule). Pause rather than delete: it is reversible with `schedule.resume`, keeps the history and works while a run is active (delete refuses then) |
| intercom | this session's blocking **outbound ask** | broker `cancel_ask` (drops the ask edge and its pending-ask record), the blocked tool call/RPC ask returns `Cancelled: session background work was stopped`, and the recipient is asked to drop the message (`cancel_message`, best-effort; the detail says whether it was reached) |

What is **not** stopped:

- **Project schedules** (decision D11): they live on the project and re-arm on any launch. An armed timer whose record cannot be read is left alone and reported `ok:false` (it might be a project schedule).
- **Inbound intercom asks** waiting on this session: the receiver cannot cancel the asker's ask. They are reported `ok:false, error:"cannot cancel inbound"` and stay in `remaining`. Outbound messages from other extensions that have not settled (`outbound_message`) and pending extension dialogs are also left alone.
- **Runs of another session** that happen to be listed in this process are never touched (`ok:false, error:"foreign_session"`).
- **Undelivered results** of stopped runs: they are delivered when the session is resumed (ownership takeover).
- A wedged detached runner that never consumes its stop file: the control channel is file based on purpose (no PID signalling, a PID cannot be proven to belong to the runner), so it is reported `timeout` and stays in `remaining`; the host may still kill the process tree it knows about.
- New work started during the stop window (for example a new prompt sent by the host in parallel) is not prevented; send the command, wait for the response, then exit.

TypeScript: `RpcClient.stopAllBackground(): Promise<StopAllBackgroundResult>`; `StopAllBackgroundCommand`, `StopAllBackgroundResponse`, `StopAllBackgroundResult` and `StopAllBackgroundItem` are exported types. Extension contract (internal): the host emits `release:stop-all:v1` with `{version:1, sessionId, deadline, contribute(name, items | Promise<items>)}`; extensions register a handler next to their `release:readiness:v1` contributor (`answerStopAllBackground` from `@selesai/code` implements the session gate and the fail-closed try/catch), start the stop synchronously and settle by `deadline`.

### Intercom (Desktop / headless clients)

These dedicated commands use the loaded `pi-intercom` extension's existing local broker connection. They do not invoke a model, run `/intercom` (a TUI overlay), or expose arbitrary extension events/tools. They work while the agent is idle or busy. `list_sessions` lists saved transcripts; `intercom_list` lists **live broker-connected endpoints**, including self, within the current routing scope.

| Command | Fields | Successful `data` |
| --- | --- | --- |
| `intercom_status` | none | `{enabled, connected, sessionId, name?, scope:{version:1,sessionId,epoch}}` (snapshot; does not initiate a connection) |
| `intercom_list` | none | `{sessionId, sessions: SessionInfo[]}` |
| `intercom_pending` | none | `{asks: [{from, message, receivedAt}]}` (unresolved inbound asks) |
| `intercom_send` | `to`, `message`, `attachments?`, `replyTo?` | Delivery object below |
| `intercom_ask` | `to`, `message`, `attachments?`, `replyTo?`, `timeoutMs?` | Delivery object plus `reply: Message` |
| `intercom_reply` | `message`, `to?`, `replyTo?`, `attachments?` | Delivery object with exact `replyTo` |

Targets use existing name/full-ID/unique-ID-prefix resolution; ambiguous and self targets fail. Prefer full IDs from a fresh live list. Text and targets must be non-empty strings; attachments use `{type: "file"|"snippet"|"context", name, content, language?}`. `reply` uses the current turn's inbound message or single pending ask; specify `replyTo` when there are multiple asks. Ordinary `send` infers the sole pending ask from its target, with the same active-turn misdirection guard as the model tool.

```json
{"id":"status-1","type":"intercom_status"}
{"id":"peers-1","type":"intercom_list"}
{"id":"send-1","type":"intercom_send","to":"peer-full-id","message":"Build finished."}
{"id":"send-1","type":"response","command":"intercom_send","success":true,"data":{"messageId":"msg-1","to":"peer-full-id","delivered":true,"delivery":"socket_delivered","retryable":false,"outcomeKnown":true}}
{"id":"ask-1","type":"intercom_ask","to":"peer-full-id","message":"Approve the change?","timeoutMs":60000}
{"id":"ask-1","type":"response","command":"intercom_ask","success":true,"data":{"messageId":"question-1","to":"peer-full-id","delivered":true,"delivery":"socket_delivered","retryable":false,"outcomeKnown":true,"reply":{"id":"answer-1","timestamp":1733234567890,"replyTo":"question-1","content":{"text":"Approved."}}}}
{"id":"pending-1","type":"intercom_pending"}
{"id":"reply-1","type":"intercom_reply","replyTo":"question-2","message":"Proceed."}
```

RPC `id` correlates the terminal command response, not the Intercom message. `data.messageId` correlates delivery/receipts and `reply.replyTo`; `data.to` is the resolved target. Delivery is `socket_delivered` or `queued`; it does **not** mean the recipient model processed the message. Delivery failures return `success:false` and an error (diagnostic delivery events can include `code`, `reason`, `retryable`, `outcomeKnown`).

`ask` waits without an LLM turn and requires a live recipient. Its `timeoutMs` is an integer from 1 to 600000, default 600000; only one shared ask waiter (RPC or model tool) is allowed per session. Concurrent asks fail rather than replacing it. A timeout is **not cancellation** of already delivered work. Other bridge operations are bounded to 30 seconds (with a one-second host cleanup margin). Replacement, reload, shutdown, and disconnect settle pending operations; stale session events/results are discarded.

The extension's confirmation policy is unchanged: with `confirmSend:true`, ordinary and inferred sends emit `extension_ui_request` with `method:"confirm"`; respond with a matching `extension_ui_response`. Refusal, missing UI, or a 30-second confirmation timeout fails closed. Explicit `replyTo`, `reply`, and `ask` skip confirmation, as in the existing Intercom tool. A missing extension fails promptly with `Intercom extension unavailable`; a disabled extension reports `enabled:false` in status and rejects other operations.

#### Structured Intercom events

These stream even without an active prompt and while the agent is busy:

Every Intercom event and `intercom_status.data` includes **`scope: {version:1, sessionId: string|null, epoch: string}`**. `scope.sessionId` is the host parent SDK session ID (as in `get_state`), never a session-file path or broker endpoint ID. Existing top-level `sessionId` is unchanged: self broker endpoint in status/connection, departing peer endpoint in `intercom_session_left`. `from.id`, `session.id`, and list IDs also remain broker endpoints. `scope.epoch` is the host binding UUID shared with subagent snapshots; it rotates on every rebind/reload, including rebinding the same session. Reject events from an obsolete `(scope.sessionId, scope.epoch)` pair. Broker reconnects within the same binding do not rotate the epoch; connection events report reconnect state. Status does not initiate a broker connection.

```json
{"id":"status-1","type":"response","command":"intercom_status","success":true,"data":{"enabled":true,"connected":true,"sessionId":"broker-self-id","scope":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid"}}}
```

| Event | Payload |
| --- | --- |
| `intercom_connection` | `enabled`, `connected`, `sessionId`, `reason?` |
| `intercom_session_joined` | `session: SessionInfo` |
| `intercom_session_left` | `sessionId` |
| `intercom_presence` | `session: SessionInfo` (name, cwd, model, lifecycle status, optional context usage) |
| `intercom_message` | `from: SessionInfo`, `message: Message` (including attachments and threading) |
| `intercom_delivery` | `result: {type:"delivered"|"delivery_failed", messageId, delivery, retryable, outcomeKnown, code?, reason?}` |
| `intercom_receipt` | `from: SessionInfo`, `receipt: {messageId, status, timestamp, detail?}` |

```json
{"type":"intercom_connection","enabled":true,"connected":true,"sessionId":"self-id","scope":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid"}}
{"type":"intercom_session_left","sessionId":"peer-full-id","scope":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid"}}
{"type":"intercom_message","from":{"id":"peer-full-id","name":"reviewer","cwd":"/project","model":"model-id","pid":123,"startedAt":1733234500000,"lastActivity":1733234567890},"message":{"id":"question-2","timestamp":1733234567890,"expectsReply":true,"content":{"text":"Can I proceed?"}},"scope":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid"}}
{"type":"intercom_receipt","from":{"id":"peer-full-id","cwd":"/project","model":"model-id","pid":123,"startedAt":1733234500000,"lastActivity":1733234567890},"receipt":{"messageId":"msg-1","status":"injected","timestamp":1733234567890},"scope":{"version":1,"sessionId":"host-session-id","epoch":"bind-uuid"}}
```

Incoming events are deduplicated per sender/message ID and include replies consumed by an ask waiter. They do not replace the normal custom-message/transcript stream; avoid rendering both as separate copies. Inbound triggering remains governed by `inboundTrigger`: idle recipients may start a turn and busy RPC recipients receive steering at the next safe boundary. Receipt statuses include `receiver_received`, `acknowledged`, `injected`, `queued`, `expired`, `cancelled`, `superseded`, and `cancellation_requested`.

TypeScript: `RpcClient` provides `intercomStatus`, `intercomList`, `intercomSend`, `intercomAsk`, `intercomReply`, `intercomPending`; `onEvent` accepts the exported `RpcEvent` union, including `IntercomRpcEvent` and extension UI requests. The `IntercomRpcCommand`, `IntercomRpcDataMap`, delivery/status/message option types are exported from `@selesai/code`.

### Commands

#### get_commands

Get available commands (extension commands, prompt templates, and skills). These can be invoked via the `prompt` command by prefixing with `/`.

```json
{"type": "get_commands"}
```

Response:
```json
{
  "type": "response",
  "command": "get_commands",
  "success": true,
  "data": {
    "commands": [
      {"name": "session-name", "description": "Set or clear session name", "source": "extension", "path": "/home/user/.pi/agent/extensions/session.ts"},
      {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.pi/agent/prompts/fix-tests.md"}
    ]
  }
}
```

Each command has:
- `name`: Command name (invoke with `/name`)
- `description`: Human-readable description (optional for extension commands)
- `source`: What kind of command:
  - `"extension"`: Registered via `pi.registerCommand()` in an extension
  - `"prompt"`: Loaded from a prompt template `.md` file
  - `"skill"`: Loaded from a skill directory (name is prefixed with `skill:`)
- `location`: Where it was loaded from (optional, not present for extensions):
  - `"user"`: User-level (`~/.pi/agent/`)
  - `"project"`: Project-level (`./.pi/agent/`)
  - `"path"`: Explicit path via CLI or settings
- `path`: Absolute file path to the command source (optional)

**Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are included with `source: "builtin"` and `interactiveOnly: true`. They are display-only: they are handled only in interactive mode and would not execute if sent via `prompt`.

#### get_skills

Get the resolved skill catalog (one entry per discovered skill with its effective enablement) plus the raw `settings.skills` patterns for round-trip toggling. Enables the extension to mirror the TUI `/skills` toggle surface without reimplementing `isEnabledByOverrides` semantics.

```json
{"type": "get_skills"}
```

Response:
```json
{
  "type": "response",
  "command": "get_skills",
  "success": true,
  "data": {
    "skills": [
      {"name": "fix-tests", "description": "Fix failing tests", "scope": "global", "pattern": "fix-tests", "enabled": true}
    ],
    "patterns": ["+fix-tests", "-legacy-skill"]
  }
}
```

Each skill entry has:
- `name`: Skill name (frontmatter `name`, falling back to the SKILL.md parent directory name)
- `description`: Frontmatter description (optional)
- `scope`: `"global"` (user-level) or `"project"` (project-level)
- `pattern`: Path relative to the skill base dir — the same pattern the TUI writes as `+pattern`/`-pattern` into `settings.skills`
- `enabled`: Resolved enablement per `isEnabledByOverrides` semantics (package-manager.ts)

`patterns` is the raw merged `global + project` `settings.skills` array. To toggle a skill, write `set_settings {scope, values:{skills:[...]}}` with `+pattern`/`-pattern` entries applied, then send `reload` (the TUI reloads after every toggle).

## Events

Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.

### 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 |
| `bash_execution_update` | Direct RPC bash command output chunk |
| `tool_execution_start` | Tool begins execution |
| `tool_execution_update` | Tool execution progress (streaming output) |
| `tool_execution_end` | Tool completes |
| `queue_update` | Pending steering/follow-up queue changed |
| `compaction_start` | Compaction begins |
| `compaction_end` | Compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) |
| `session_tree` | Session tree navigated (new leaf, old leaf, optional summary entry) |
| `extension_error` | Extension threw an error |

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

```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":{...}}}
```

### bash_execution_update

Emitted once for each output chunk from a direct `bash` command. `id` matches the command's `id`, allowing clients to associate output with the correct command.

Events stream all output while the command runs, even if the final `bash` response's `output` is truncated.

```json
{
  "type": "bash_execution_update",
  "id": "req-1",
  "delta": "total 48\n"
}
```

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

### queue_update

Emitted whenever the pending steering or follow-up queue changes.

```json
{
  "type": "queue_update",
  "steering": ["Focus on error handling"],
  "followUp": ["After that, summarize the result"]
}
```

### compaction_start / compaction_end

Emitted when compaction runs, whether manual or automatic.

```json
{"type": "compaction_start", "reason": "threshold"}
```

The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`.

```json
{
  "type": "compaction_end",
  "reason": "threshold",
  "result": {
    "summary": "Summary of conversation...",
    "firstKeptEntryId": "abc123",
    "tokensBefore": 150000,
    "estimatedTokensAfter": 32000,
    "details": {}
  },
  "aborted": false,
  "willRetry": false
}
```

If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.

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

If compaction failed (e.g., API quota exceeded), `result` is `null`, `aborted` is `false`, and `errorMessage` contains the error description.

### session_tree

Emitted after a `navigate_tree` (or an extension-initiated tree navigation) moves the session to a new leaf. `oldLeafId`/`newLeafId` are the session tree leaves before and after the navigation; `summaryEntry` is present when the navigation summarized the branch. `fromExtension` is true when the navigation was initiated by an extension.

```json
{
  "type": "session_tree",
  "oldLeafId": "def456",
  "newLeafId": "abc123",
  "summaryEntry": null,
  "fromExtension": false
}
```

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

### extension_error

Emitted when an extension throws an error.

```json
{
  "type": "extension_error",
  "extensionPath": "/path/to/extension.ts",
  "event": "tool_call",
  "error": "Error message..."
}
```

## Extension UI Protocol

Extensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm()`, etc. In RPC mode, these are translated into a request/response sub-protocol on top of the base command/event flow.

There are two categories of extension UI methods:

- **Dialog methods** (`select`, `multiselect`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`.
- **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `working`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it.

If a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts.

Some `ExtensionUIContext` methods are not supported or degraded in RPC mode because they require direct TUI access:
- `custom()` returns `undefined`
- `setWorkingMessage()`, `setWorkingVisible()`, `setWorkingIndicator()` emit a `working` extension_ui_request (see below); `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
- `getEditorText()` returns `""`
- `getToolsExpanded()` returns `false`
- `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling)
- `getAllThemes()` returns `[]`
- `getTheme()` returns `undefined`
- `setTheme()` returns `{ success: false, error: "..." }`

Note: `ctx.mode` is `"rpc"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === "tui"` to guard TUI-specific features like `custom()` that require a real terminal.

### Extension UI Requests (stdout)

All requests have `type: "extension_ui_request"`, a unique `id`, and a `method` field.

#### select

Prompt the user to choose from a list. Dialog methods with a `timeout` field include the timeout in milliseconds; the agent auto-resolves with `undefined` if the client doesn't respond in time.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-1",
  "method": "select",
  "title": "Allow dangerous command?",
  "options": ["Allow", "Block"],
  "timeout": 10000
}
```

Expected response: `extension_ui_response` with `value` (the selected option string) or `cancelled: true`.

#### multiselect

Prompt the user to choose zero or more options from a list. Dialog methods with a `timeout` field include the timeout in milliseconds; the agent auto-resolves with `undefined` if the client doesn't respond in time.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-10",
  "method": "multiselect",
  "title": "Which regions?",
  "options": ["us-east", "eu-west", "ap-southeast"],
  "timeout": 10000
}
```

Expected response: `extension_ui_response` with `values` (the selected option strings) or `cancelled: true`.

#### confirm

Prompt the user for yes/no confirmation.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-2",
  "method": "confirm",
  "title": "Clear session?",
  "message": "All messages will be lost.",
  "timeout": 5000
}
```

Expected response: `extension_ui_response` with `confirmed: true/false` or `cancelled: true`.

#### input

Prompt the user for free-form text.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-3",
  "method": "input",
  "title": "Enter a value",
  "placeholder": "type something..."
}
```

Expected response: `extension_ui_response` with `value` (the entered text) or `cancelled: true`.

#### editor

Open a multi-line text editor with optional prefilled content.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-4",
  "method": "editor",
  "title": "Edit some text",
  "prefill": "Line 1\nLine 2\nLine 3"
}
```

Expected response: `extension_ui_response` with `value` (the edited text) or `cancelled: true`.

#### notify

Display a notification. Fire-and-forget, no response expected.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-5",
  "method": "notify",
  "message": "Command blocked by user",
  "notifyType": "warning"
}
```

The `notifyType` field is `"info"`, `"warning"`, or `"error"`. Defaults to `"info"` if omitted.

#### setStatus

Set or clear a status entry in the footer/status bar. Fire-and-forget.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-6",
  "method": "setStatus",
  "statusKey": "my-ext",
  "statusText": "Turn 3 running..."
}
```

Send `statusText: undefined` (or omit it) to clear the status entry for that key.

#### setWidget

Set or clear a widget (block of text lines) displayed above or below the editor. Fire-and-forget.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-7",
  "method": "setWidget",
  "widgetKey": "my-ext",
  "widgetLines": ["--- My Widget ---", "Line 1", "Line 2"],
  "widgetPlacement": "aboveEditor"
}
```

Send `widgetLines: undefined` (or omit it) to clear the widget. The `widgetPlacement` field is `"aboveEditor"` (default) or `"belowEditor"`. Only string arrays are supported in RPC mode; component factories are ignored.

#### setTitle

Set the terminal window/tab title. Fire-and-forget.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-8",
  "method": "setTitle",
  "title": "pi - my project"
}
```

#### set_editor_text

Set the text in the input editor. Fire-and-forget.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-9",
  "method": "set_editor_text",
  "text": "prefilled text for the user"
}
```

#### working

Control the streaming "working" loader row. Fire-and-forget; the host renders the loader. This mirrors the TUI's `setWorkingMessage()` / `setWorkingVisible()` / `setWorkingIndicator()` extension methods.

```json
{
  "type": "extension_ui_request",
  "id": "uuid-10",
  "method": "working",
  "message": "Analyzing repository...",
  "visible": true,
  "frames": ["⠋", "⠙", "⠹"],
  "intervalMs": 80
}
```

Fields (all optional, applied as a patch):
- `message`: phase text shown next to the loader. Omit to keep (or restore to default).
- `visible`: show/hide the loader row.
- `frames`: custom animation frames for the normal streaming loader. An empty array hides the indicator; omitting restores the default spinner. Compaction/retry loaders keep their built-in styling.
- `intervalMs`: frame interval in milliseconds for animated indicators.

### Extension UI Responses (stdin)

Responses are sent for dialog methods only (`select`, `multiselect`, `confirm`, `input`, `editor`). The `id` must match the request.

#### Value response (select, input, editor)

```json
{"type": "extension_ui_response", "id": "uuid-1", "value": "Allow"}
```

#### Values response (multiselect)

```json
{"type": "extension_ui_response", "id": "uuid-10", "values": ["us-east", "ap-southeast"]}
```

#### Confirmation response (confirm)

```json
{"type": "extension_ui_response", "id": "uuid-2", "confirmed": true}
```

#### Cancellation response (any dialog)

Dismiss any dialog method. The extension receives `undefined` (for select/multiselect/input/editor) or `false` (for confirm).

```json
{"type": "extension_ui_response", "id": "uuid-3", "cancelled": true}
```

## 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) - `AgentMessage`, `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, extension UI request/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.

For a complete example of handling the extension UI protocol, see [`examples/rpc-extension-ui.ts`](../examples/rpc-extension-ui.ts) which pairs with the [`examples/extensions/rpc-demo.ts`](../examples/extensions/rpc-demo.ts) extension.

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

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

function attachJsonlReader(stream, onLine) {
    const decoder = new StringDecoder("utf8");
    let buffer = "";

    stream.on("data", (chunk) => {
        buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);

        while (true) {
            const newlineIndex = buffer.indexOf("\n");
            if (newlineIndex === -1) break;

            let line = buffer.slice(0, newlineIndex);
            buffer = buffer.slice(newlineIndex + 1);
            if (line.endsWith("\r")) line = line.slice(0, -1);
            onLine(line);
        }
    });

    stream.on("end", () => {
        buffer += decoder.end();
        if (buffer.length > 0) {
            onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
        }
    });
}

attachJsonlReader(agent.stdout, (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");
});
```
