In RPC mode, `shutdownHandler` only set a `shutdownRequested` flag. That flag was checked in two places: after a stdin command was handled and on `agent_settled`. If an extension called `ctx.shutdown()` while the session was idle (for example from a timer), neither check ever ran. The process stayed alive until the client sent another command. Interactive mode already shuts down right away in this case. Changes in `packages/coding-agent/src/modes/rpc/rpc-mode.ts`: - **One check for all paths:** `checkShutdownRequested()` shuts down only when a request is pending, input is attached, no command is being handled, and the session is idle. Each of those ending calls it again: command completion, prompt settle, `agent_settled`, and input attach. - **Idle requests:** the shutdown handler schedules the check on the next tick, so the requesting handler finishes first. The conditions are checked when the tick runs, not when it is scheduled: a stdin command that arrives in between is handled first, and shutdown follows when it finishes. - **Requests during startup:** a request made before input is attached (for example a synchronous `ctx.shutdown()` in `session_start`) is checked once the input reader is attached. - **Requests during a command:** shutdown waits until the command finishes, including extension commands. A `prompt` command returns before `session.prompt()` settles, so it stays counted as in flight until the prompt settles, and the shutdown check runs then. This also fixes an existing ordering problem: an extension command that called `ctx.shutdown()` could exit the process before its `prompt` response was written. - **Repeated requests:** `checkShutdownRequested()` does nothing once shutdown has started, so a second `ctx.shutdown()` does not call `process.exit` in the middle of async cleanup. A second stdin EOF or signal still forces exit, as before. Shutdown requested during a running turn is still handled on `agent_settled`, as before. A request made while an extension-started compaction runs (not idle, no run to settle) still waits for the next command or run; `docs/rpc.md` and `docs/extensions.md` note this. Docs (`docs/rpc.md`, `docs/extensions.md`) and the CHANGELOG are updated. ## Testing - Added three tests to `test/rpc-prompt-response-semantics.test.ts`: - an async `ctx.shutdown()` on an idle session exits; - an extension command that calls `ctx.shutdown()` gets its `prompt` response written before exit; - a command that arrives after an idle request but before the scheduled check runs gets its response written before exit. All three fail without the change and pass with it. - `npm run check` passes. All `test/rpc*` tests pass. - In `./test.sh`, all coding-agent tests pass. The only failures were timeouts in durable/harness tests (`store-storage`, `tools-images`, connection close), which this change does not touch. - Reproduction from the issue: run `pi --mode rpc --no-session -ne -e ./shutdown-probe.mjs` with stdin held open and nothing sent. - Before the change, the process stayed alive until a timeout killed it (exit 124). - After the change, `session_shutdown` fires and the process exits with code 0. - A synchronous `ctx.shutdown()` in `session_start` also exits with code 0. Fixes #11599 Requested by @petrroll --------- Co-authored-by: triagent <triagent@localhost> Co-authored-by: Petr Houška <houskape@gmail.com>
8.9 KiB
RPC Mode
RPC mode runs Pi as a long-lived subprocess controlled through JSON records on stdin and stdout. Use it for language-independent integrations, process isolation, IDEs, and custom user interfaces.
For an in-process Node.js or Bun integration, prefer the SDK. For a subprocess-based TypeScript integration, prefer the exported RpcClient, which starts Pi, correlates responses, exposes typed command methods, and delivers events to listeners.
| Interface | Process boundary | Control model | Best fit |
|---|---|---|---|
| SDK | In process | Direct TypeScript methods and events | Node.js or Bun hosts that want complete API access |
| RPC | Child process | JSONL commands, responses, and events | Other languages, isolated processes, IDEs, or custom clients |
Start RPC mode
pi --mode rpc --no-session
Normal CLI options still select the working folder, model, tools, resources, and session behavior. Common choices include --provider, --model, --name, --no-session, and --session-dir. See Command Line for the complete, version-specific interface; pi --help is authoritative for the installed version.
RPC mode rejects @file prompt arguments. Send prompts through the prompt command instead.
Protocol records
The protocol has four record families:
| Direction | Record | Purpose |
|---|---|---|
| stdin | Command | Ask Pi to prompt, inspect state, change configuration, or manage the session |
| stdout | response |
Report whether one command succeeded and return any command data |
| stdout | Session event | Stream run, message, tool, queue, compaction, and retry activity |
| Both | Extension UI record | Forward supported extension interactions between Pi and the client |
See RPC Commands, JSON Event Stream, and RPC Extension UI for the canonical record definitions.
Correlate commands and responses
Every command accepts an optional string id. A matching response repeats it:
{"id":"req-1","type":"get_state"}
{"id":"req-1","type":"response","command":"get_state","success":true,"data":{"...":"..."}}
Use unique IDs whenever more than one command can be outstanding. Command handling is asynchronous, so clients should correlate by ID rather than response order.
Session events generally have no command ID because they describe session activity. bash_execution_update is the exception: when the originating bash command has an ID, its output events repeat that ID.
An extension_ui_response uses the ID supplied by its extension_ui_request. It does not produce a normal command response.
Framing
RPC uses strict JSONL framing. Write one complete JSON object per record and terminate it with LF (\n). Read stdout as a byte or UTF-8 stream and split records only on LF. Strip an optional preceding carriage return to accept CRLF input.
Do not use a generic line reader that treats Unicode line or paragraph separators as record boundaries. In particular, Node.js readline also splits on U+2028 and U+2029, which are valid inside JSON strings.
Read stdout continuously. Pi honors stdout backpressure, but a client that stops reading can stall the process. Honor stdin backpressure when writing commands. Stdout is reserved for protocol records; diagnostics and application logging go to stderr.
Run lifecycle
A successful prompt response means the prompt was accepted, queued, or handled. It does not mean model work completed:
{"id":"req-2","type":"prompt","message":"Review this repository"}
{"id":"req-2","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}
data.disposition reports what happened to the prompt. If it is "handled", no run started for this prompt, so don't wait for agent_settled. See RPC Commands for all values.
Continue consuming events after that response. agent_end marks the end of one low-level agent run, but retries, overflow recovery, compaction, steering, or follow-up work can still follow. Wait for agent_settled when the client needs to know Pi will not continue automatically.
Subscribe before sending a prompt to avoid missing a fast completion. RpcClient.promptAndWait() does this internally. If using separate RpcClient calls, install the event listener before prompt() and call waitForIdle() only while a run is active.
Errors
A failed command returns one response with success: false:
{"id":"req-3","type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}
Malformed JSON produces a parse response without a request ID:
{"type":"response","command":"parse","success":false,"error":"Failed to parse command: Unexpected token..."}
A success response only covers command handling. Provider failures and aborts after a prompt is accepted appear in the message and event stream.
Clients must also handle child-process startup failures, unexpected exits, stderr diagnostics, cancellation, and their own deadlines. Do not parse stderr as protocol data.
Shutdown
Close the child's stdin to request an orderly shutdown. Pi disposes the active runtime before exiting. Clients should still handle process signals and unexpected exits.
An extension can also request shutdown with ctx.shutdown(). If the session is idle and no command is being handled, Pi shuts down right away. Otherwise it shuts down once the current command finishes (for prompt, once the prompt settles) or the active run emits agent_settled. (A request made while an extension-started compaction is running waits for the next command or run to finish.)
Minimal client
This Python example uses a binary pipe reader, which splits on LF without treating Unicode separators as protocol boundaries:
import json
import subprocess
process = subprocess.Popen(
["pi", "--mode", "rpc", "--no-session"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
assert process.stdin is not None
assert process.stdout is not None
command = {"id": "prompt-1", "type": "prompt", "message": "Hello"}
process.stdin.write(json.dumps(command).encode("utf-8") + b"\n")
process.stdin.flush()
while line := process.stdout.readline():
record = json.loads(line)
if record.get("type") == "message_update":
update = record["assistantMessageEvent"]
if update["type"] == "text_delta":
print(update["delta"], end="", flush=True)
elif record.get("type") == "agent_settled":
print()
break
process.stdin.close()
process.wait()
For maintained TypeScript clients, use the checked RPC client example. It requires a built Pi CLI because the repository example points to dist/cli.js.
Reference
- RPC Commands: every stdin command and response
- JSON Event Stream: shared stdout session events and streaming reconstruction
- RPC Extension UI: dialogs, notifications, responses, and limitations
- Message Types: messages and content blocks used by responses and events
- Session File Format: entries returned by session commands
rpc-types.ts: exported TypeScript protocol definitionsRpcClient: subprocess client implementation
Moved reference anchors
The detailed references formerly on this page now have dedicated pages. These anchors preserve existing links.
Command details moved to RPC Commands.
Event details moved to JSON Event Stream.
Extension interaction details moved to RPC Extension UI.