# RPC mode

> Process integration via rpc-entry: command IDs, prompt-during-compaction constraints, JSON stream behavior, and unknown-command handling.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human docs: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1
- Complete Markdown: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1/llms-full.txt

## Source Files

- `packages/coding-agent/package.json`
- `packages/coding-agent/src/cli/args.ts`
- `packages/coding-agent/test/suite/regressions/5868-rpc-unknown-command-id.test.ts`
- `packages/coding-agent/test/suite/regressions/7150-rpc-prompt-during-compaction.test.ts`
- `packages/coding-agent/test/suite/regressions/7290-json-stream-linear.test.ts`
- `packages/coding-agent/src/core/agent-session-runtime.ts`

---

---
title: "RPC mode"
description: "Process integration via rpc-entry: command IDs, prompt-during-compaction constraints, JSON stream behavior, and unknown-command handling."
---

RPC mode runs `@earendil-works/pi-coding-agent` as a headless JSONL process over stdin/stdout. Start it with `pi --mode rpc` or the `./rpc-entry` package export; the process title becomes `pi-rpc`, stdin is reserved for protocol frames, and `runRpcMode` owns command dispatch, event streaming, and process lifecycle.

## When to use RPC

| Integration | Entry | Use when |
|-------------|-------|----------|
| Subprocess protocol | `pi --mode rpc` or `@earendil-works/pi-coding-agent/rpc-entry` | IDE host, another language runtime, or any process that needs JSON control of the agent |
| Typed Node client | `RpcClient` (`src/modes/rpc/rpc-client.ts`) | TypeScript host that wants request IDs and event listeners without hand-rolling framing |
| In-process embed | package main / SDK (`AgentSession`, `AgentSessionRuntime`) | Same Node process; prefer this over spawning RPC |

RPC is one of four run modes (`interactive`, `print`/`text`, `json`, `rpc`). Print/JSON modes also emit structured events; RPC adds a full command surface (prompt, model, session tree, bash, compaction, extension UI) and keeps the process alive until stdin ends or a signal arrives.

## Start the process

<CodeGroup>

```bash title="CLI"
pi --mode rpc \
  --provider anthropic \
  --model claude-sonnet-4-20250514 \
  --name my-session
```

```js title="rpc-entry (package export)"
// package.json exports: "./rpc-entry" → dist/rpc-entry.js
// Equivalent to: main(["--mode", "rpc", ...argv])
import "@earendil-works/pi-coding-agent/rpc-entry";
```

```ts title="RpcClient spawn"
import { RpcClient } from "./src/modes/rpc/rpc-client.ts";

const client = new RpcClient({
  cliPath: "dist/cli.js",
  provider: "anthropic",
  model: "claude-sonnet-4-20250514",
  cwd: process.cwd(),
});
await client.start();
```

</CodeGroup>

### Startup constraints

| Constraint | Behavior |
|------------|----------|
| `@file` arguments | Rejected: `@file arguments are not supported in RPC mode` |
| Piped stdin content | Not read as prompt text; stdin is the JSONL command stream |
| Model required | Process exits if no model is available (non-interactive modes require a model) |
| Catalog refresh | Background model-catalog refresh starts with a 15s abort timeout |
| Session options | `--provider`, `--model`, `--name`/`-n`, `--no-session`, `--session-dir`, tools/extensions flags apply as for other modes |

Process signals: `SIGTERM` exits with code `143`; on non-Windows, `SIGHUP` exits with `129`. stdin `end` triggers orderly dispose and exit.

## Wire protocol

### Framing

- One JSON object per record.
- Record delimiter is LF (`\n`) only.
- Trailing CR on CRLF input is stripped; payload strings may contain `U+2028` / `U+2029`.
- Do **not** use Node `readline` (it splits on Unicode separators that are valid inside JSON strings).
- Implementation: `serializeJsonLine` / `attachJsonlLineReader` in `src/modes/rpc/jsonl.ts`.

### Message kinds on stdout

| Kind | Discriminator | Role |
|------|---------------|------|
| Response | `type: "response"` | Correlated command result (`success` true/false) |
| Session event | `type: "agent_start"`, `message_update`, … | Live agent/session stream (`toJsonEvent`) |
| Extension UI | `type: "extension_ui_request"` | Host must answer interactive extension dialogs |
| Extension error | `type: "extension_error"` | Extension handler threw |

Commands arrive on stdin as JSON objects with a required `type` and optional `id`. Extension UI replies use `type: "extension_ui_response"` and are **not** routed through the command switch.

### Command IDs

Every `RpcCommand` may include `id?: string`. On success or failure, the matching `response` echoes that `id`.

- Unknown commands still echo the request `id`.
- Parse failures use `command: "parse"` and omit `id` when the line never produced a command object.
- Direct `bash` commands also attach `id` to streamed `bash_execution_update` events so hosts can multiplex concurrent shell output.

```json
{"id":"req-1","type":"prompt","message":"List open TODOs"}
{"id":"req-1","type":"response","command":"prompt","success":true}
```

### Prompt response semantics

For `prompt` only:

1. Preflight runs (extension commands, compaction gate, streamingBehavior, auth).
2. On preflight success, RPC emits `{ type: "response", command: "prompt", success: true }` **before** the agent run finishes.
3. On preflight failure, RPC emits `{ success: false, error: "..." }` and does not start the agent.
4. Failures after acceptance appear in the event stream (`agent_*`, messages), not as a second `response` for the same id.

`steer`, `follow_up`, and most other commands return their response after the handler completes.

## Command catalog

Commands are defined in `RpcCommand` / `RpcResponse` (`src/modes/rpc/rpc-types.ts`) and dispatched in `handleCommand` (`src/modes/rpc/rpc-mode.ts`).

### Prompting

| `type` | Required fields | Notes |
|--------|-----------------|-------|
| `prompt` | `message` | Optional `images`, `streamingBehavior: "steer" \| "followUp"`. Async response after preflight |
| `steer` | `message` | Queue while running; no extension-command form |
| `follow_up` | `message` | Queue until idle |
| `abort` | — | Abort current agent operation |
| `new_session` | — | Optional `parentSession`; may return `data.cancelled` |

While streaming, bare `prompt` without `streamingBehavior` fails with:

`Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.`

Slash messages starting with `/` that match an extension command run immediately (even while streaming). Skill (`/skill:…`) and prompt-template expansions run before queue/send.

### State, model, thinking, queues

| `type` | Purpose |
|--------|---------|
| `get_state` | Snapshot: model, thinkingLevel, isStreaming, **isCompacting**, steering/follow-up modes, sessionFile/id/name, autoCompactionEnabled, messageCount, pendingMessageCount |
| `get_messages` | Full `AgentMessage[]` |
| `set_model` | `provider` + `modelId` (error if not found) |
| `cycle_model` | Next scoped model; `data` may be `null` |
| `get_available_models` | `{ models }` |
| `set_thinking_level` | `level`: `off` \| `minimal` \| `low` \| `medium` \| `high` \| `xhigh` \| `max` |
| `cycle_thinking_level` | Next supported level; `data` may be `null` |
| `get_available_thinking_levels` | Levels for current model |
| `set_steering_mode` | `"all"` \| `"one-at-a-time"` |
| `set_follow_up_mode` | `"all"` \| `"one-at-a-time"` |

### Compaction and retry

| `type` | Purpose |
|--------|---------|
| `compact` | Manual compaction; optional `customInstructions`; returns `CompactionResult` |
| `set_auto_compaction` | `enabled: boolean` |
| `set_auto_retry` | `enabled: boolean` |
| `abort_retry` | Cancel in-flight retry delay |

### Bash

| `type` | Purpose |
|--------|---------|
| `bash` | Run shell; optional `excludeFromContext`; streams `bash_execution_update` with command `id`; final `BashResult` in response |
| `abort_bash` | Abort running bash |

Bash output is recorded into session state and reaches the LLM on the **next** `prompt`, not immediately.

### Session tree and export

| `type` | Purpose |
|--------|---------|
| `get_session_stats` | Tokens, cost, contextUsage |
| `export_html` | Optional `outputPath` |
| `switch_session` | `sessionPath`; may cancel via extension |
| `fork` | `entryId`; returns selected text + cancelled |
| `clone` | Fork at current leaf (`position: "at"`); errors if no leaf |
| `get_fork_messages` | User messages available for fork |
| `get_entries` | Optional `since` entry id cursor; unknown `since` → error |
| `get_tree` | Full tree + `leafId` |
| `get_last_assistant_text` | `{ text: string \| null }` |
| `set_session_name` | Non-empty trimmed `name` |

### Slash command discovery

`get_commands` returns extension commands, prompt templates, and skills as `RpcSlashCommand[]`:

```ts
{
  name: string;           // invoke via prompt as /name
  description?: string;
  source: "extension" | "prompt" | "skill";
  sourceInfo: SourceInfo; // path, source, scope, origin, baseDir?
}
```

Skill names are returned as `skill:<name>`. Built-in TUI-only commands (`/settings`, `/hotkeys`, …) are not listed and would not run if sent as a prompt.

## Event stream

Session events are forwarded with `toJsonEvent` after `session.subscribe`.

| Event | Meaning |
|-------|---------|
| `agent_start` / `agent_end` | Low-level agent run boundaries; `agent_end` may include `willRetry` |
| `agent_settled` | No automatic retry, compaction retry, or queued continuation remains |
| `turn_start` / `turn_end` | Assistant turn + tool results |
| `message_start` / `message_update` / `message_end` | Message lifecycle |
| `bash_execution_update` | Direct RPC bash chunk (`id` + `delta`) |
| `tool_execution_start` / `_update` / `_end` | Tool lifecycle |
| `queue_update` | Steering / follow-up queues changed |
| `compaction_start` / `compaction_end` | Manual or auto compaction |
| `auto_retry_*` | Transient provider retry loop |
| `summarization_retry_*` | Compaction/branch-summary retry loop |
| `extension_error` | Extension throw |

### Linear JSON streaming (`message_update`)

Wire `message_update` events are **delta-only**:

- No cumulative `message` snapshot on the event.
- No `assistantMessageEvent.partial` field.
- Clients assemble text/tool calls from `message_start` + deltas (`contentIndex`), then treat `message_end.message` as authoritative.

This keeps stream size linear in generated content (not quadratic). Regression coverage: issue `#7290` / `7290-json-stream-linear.test.ts`.

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

## Compaction interaction

Manual and automatic compaction set an in-progress gate on `AgentSession`.

If an RPC `prompt` arrives while compaction is running:

1. Preflight reports failure (`preflightResult(false)`).
2. Response error text includes `compaction is in progress`.
3. The probe text is **not** added to agent messages or the session file.
4. No `agent_start` / `agent_settled` for that rejected prompt.

```json
{"id":"p1","type":"response","command":"prompt","success":false,
 "error":"Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry."}
```

Use `get_state.isCompacting` and/or `compaction_*` events to wait before prompting. Related: [Context compaction](/compaction).

## Unknown command handling

Unrecognized `type` values do not crash the process. The dispatcher returns:

```json
{
  "id": "test",
  "type": "response",
  "command": "foobar",
  "success": false,
  "error": "Unknown command: foobar"
}
```

The request `id` is preserved (regression `#5868`). Malformed JSON lines produce:

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

## Extension UI over RPC

When extensions call UI APIs, RPC emits `extension_ui_request` and waits for `extension_ui_response` (except fire-and-forget methods).

| Method | Host action |
|--------|-------------|
| `select` / `input` / `editor` | Reply with `{ type, id, value }` or `{ cancelled: true }` |
| `confirm` | Reply with `{ confirmed: boolean }` or cancelled |
| `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text` | Fire-and-forget on the wire |

Unsupported in RPC UI context (no-op or fixed error): raw terminal input, working-indicator/loader APIs, custom editor components, autocomplete composition, theme switching, tool expansion toggles.

## Minimal host loop

```text
Host                              pi --mode rpc
  |  {"id":"1","type":"get_state"}     |
  | ---------------------------------> |
  |  {"id":"1","type":"response",...}  |
  | <--------------------------------- |
  |  {"id":"2","type":"prompt",...}    |
  | ---------------------------------> |
  |  {"id":"2","type":"response",      |
  |   "command":"prompt","success":true}|
  | <--------------------------------- |
  |  {"type":"agent_start"} ...        |
  |  {"type":"message_update",...}     |
  |  {"type":"agent_settled"}          |
  | <--------------------------------- |
```

<Steps>
  <Step title="Spawn">
    Start `pi --mode rpc` (or `rpc-entry` / `RpcClient`) with provider/model auth available.
  </Step>
  <Step title="Frame I/O">
    Write one JSON object + `\n` per command; read stdout by splitting on `\n` only.
  </Step>
  <Step title="Correlate">
    Put unique `id` values on commands; match `type: "response"` lines by `id` and `command`.
  </Step>
  <Step title="Drive turns">
    Send `prompt`; treat `success: true` as acceptance; wait for `agent_settled` before assuming the turn is fully idle.
  </Step>
  <Step title="Handle gates">
    If `isCompacting` or compaction events are active, do not prompt until compaction finishes. Supply `streamingBehavior` when the agent is already streaming.
  </Step>
</Steps>

## Error and verification matrix

| Situation | Signal |
|-----------|--------|
| Unknown `type` | `success: false`, `error: "Unknown command: …"`, same `id` |
| Invalid JSON line | `command: "parse"`, `success: false` |
| Prompt while compacting | `success: false`, compaction-in-progress message; no persistence |
| Prompt while streaming without `streamingBehavior` | Preflight error on `prompt` |
| Missing model/auth | Preflight error before acceptance |
| Unknown `get_entries.since` | `Entry not found: …` |
| Empty `set_session_name` | `Session name cannot be empty` |
| Clone with no leaf | `Cannot clone session: no current entry selected` |

## Architecture sketch

```mermaid
flowchart LR
  subgraph Host
    Client[Host / RpcClient]
  end
  subgraph Process["pi process"]
    Entry["rpc-entry / cli --mode rpc"]
    Main[main → AgentSessionRuntime]
    RPC[runRpcMode]
    Sess[AgentSession]
  end
  Client -->|"JSONL commands stdin"| RPC
  Entry --> Main --> RPC
  RPC -->|"handleCommand"| Sess
  Sess -->|"session.subscribe → toJsonEvent"| RPC
  RPC -->|"JSONL responses + events stdout"| Client
```

## Related pages

<CardGroup>
  <Card title="Run modes" href="/run-modes">
    How RPC sits next to interactive, print, and JSON modes.
  </Card>
  <Card title="Session runtime" href="/session-runtime">
    AgentSessionRuntime lifecycle, rebind, and non-TUI embedding.
  </Card>
  <Card title="Context compaction" href="/compaction">
    Manual/auto compaction triggers and why prompts are blocked mid-compact.
  </Card>
  <Card title="Package exports" href="/package-exports">
    npm surface including `./rpc-entry` and main SDK export.
  </Card>
  <Card title="SDK" href="/sdk">
    In-process alternative when a subprocess is not required.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Retry, SIGTERM cleanup, and event-settlement issues.
  </Card>
</CardGroup>
