Pi 的 RPC 模式与 JSON 事件流

2026-09-26 pi 0.87.1 实测抓取,非文档转抄

pi 是一个跑在终端里的 coding agent,但它并不只有 TUI。同一个 agent、同一套 session 与工具,可以用四种方式驱动:交互式 TUI、print、--mode json、--mode rpc。后两种是给人写程序用的——当你的脚本、Web 后端、IDE 插件需要「结构化进度」而不是一段最终文本时,就该看这一篇。

下面所有事件样本都是我在本机 pi 0.87.1 上真跑出来的(模型 deepseek-v4-pro,工作目录 /tmp/pi-demo),为了排版把过长的字段折成了 …,其余保持原样。

1 · 先选对接口

模式输出进程生命周期什么时候用
Interactive终端 UI人退出为止人在跟它对话
Print --printstdout 一段最终文本一次调用脚本只要结果,命令替换/管道
JSON --mode jsonstdout JSONL 事件流一次调用需要结构化的进度,但不再发指令
RPC --mode rpcstdin 收命令,stdout 出 response + 事件长驻子进程需要双向控制:换模型、打断、追加 steer、查状态

还有一条路是 SDK:进程内嵌,直接拿 TypeScript 方法和事件,没有进程边界。判断标准很简单——同语言且能接受同一个进程里跑 agent,用 SDK;跨语言、要隔离、要做自定义客户端,用 RPC。

另外:不给 --print 时,只要 stdin/stdout 不是 TTY,pi 也会自动落到 print 模式。所以 pi "…" 在管道里不会弹出 TUI。

2 · JSON 模式:一条真实的事件流

pi --mode json "只回复两个字母:OK" > events.jsonl   # 15 行

把它按类型摊开(左边是行号,中间是 type):

 1 session         {"version":3,"id":"01a0dccb-…","cwd":"/tmp/pi-demo"}
 2 agent_start
 3 turn_start
 4 message_start     role=system    content=""  sections={preamble, tools, …}
 5 message_end       role=system
 6 message_start     role=user      [{"type":"text","text":"只回复两个字母:OK"}]
 7 message_end       role=user
 8 message_start     role=assistant content=[]  stopReason="pending" usage.totalTokens=0
 9 message_update    assistantMessageEvent={"type":"text_start","contentIndex":0}   usage.totalTokens=0
10 message_update    assistantMessageEvent={"type":"text_delta","contentIndex":0,"delta":"OK"}  usage.totalTokens=0
11 message_update    assistantMessageEvent={"type":"text_end","contentIndex":0,"content":"OK"}   usage.totalTokens=3554
12 message_end       role=assistant [{"type":"text","text":"OK"}] stopReason="stop"
13 turn_end          {message, toolResults:[]}
14 agent_end         messages=[3 条] willRetry=false
15 agent_settled

几个直接从数据里读出来的事实:

只想要完成的文本,一行 jq 就够(stderr 记得丢掉,stdout 是协议专用):

pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'

3 · 分帧:只按 LF 切

两个模式共用同一套 framing:一条记录 = 一个完整 JSON 对象 + \n。听起来 trivial,但这里有个真会咬人的坑:

Node.js readline 不能用

readline 除了 \n/\r\n,还把 U+2028(LINE SEPARATOR)和 U+2029(PARAGRAPH SEPARATOR)当行边界。而这两个字符在 JSON 字符串里是合法的——它们出现在模型输出、抓取的网页文本、或者任何带日文的正文里,你的记录就会被从中间劈开,JSON.parse 当场炸。

正确做法:拿字节流 / UTF-8 decoder,只按 LF 切,容忍一个可选的前导 \r。

另外三条纪律:

4 · 事件词表

下面是 AgentSessionEvent 在 JSON/RPC 里会出现的全集(JSON 模式与 RPC 模式共享这套形状,RPC 另有两条独有的)。

骨架

事件字段含义
agent_start—一次低层 agent run 开始
turn_start—一个 turn 开始(= 一次助手响应 + 它引发的工具调用与结果)
turn_endmessage, toolResults该 turn 结束
agent_endmessages, willRetry该低层 run 结束,messages 是本次生成的消息
agent_settled—pi 不再有自动后续动作(重试/溢出恢复/压缩/排队消息都处理完)

消息与流式

事件字段含义
message_startmessage消息开始(assistant 的 stopReason 起初是 "pending")
message_updateusage, assistantMessageEvent内容块增量,见下一节
message_endmessage消息完成,权威最终版本

工具执行

事件字段含义
tool_execution_starttoolCallId, toolName, args开始执行
tool_execution_update+ partialResult工具自己上报的部分结果(替换还是追加取决于该工具的契约)
tool_execution_endresult, isError结束

用 toolCallId 串生命周期,别按顺序猜。

状态、压缩、重试

事件含义
queue_update {steering, followUp}待处理队列变了,两个字段是完整当前队列,不是增量
entry_appended扩展通过 pi.appendEntry() 写了自定义 session 条目
session_info_changed / thinking_level_changed会话显示名 / thinking 等级变了
compaction_start / compaction_end压缩开始 / 结束,reason ∈ manual|threshold|overflow;成功带 result(summary、tokensBefore、…),被中止 aborted:true,失败则 errorMessage
auto_retry_start / auto_retry_end助手 turn 的自动重试(attempt/maxAttempts/delayMs;最终失败时 success:false + finalError)
summarization_retry_*压缩 / 分支摘要的重试三连

仅 RPC 有

事件含义
bash_execution_update直接发的 bash 命令每块输出一条,id 回指命令 ID。命令 response 里的输出可能被截断,流里的不会
extension_error扩展 handler 抛异常:extensionPath / event / error

扩展 UI(对话框、通知)是 RPC 的另一个子协议,不属于 AgentSessionEvent。

5 · 重建流式消息

线上格式的 message_update 是纯增量:SDK 里那种累积 message 快照和 assistantMessageEvent.partial 都被剥掉了,所以流量是线性的。代价是客户端要自己拼。

嵌套的 assistantMessageEvent.type:

type额外字段
text_start / thinking_start / toolcall_startcontentIndex(toolcall 另有 id、toolName)
text_delta / thinking_delta / toolcall_deltacontentIndex, delta(toolcall 的 delta 是序列化后的参数片段)
text_end / thinking_endcontentIndex, content ← 权威
toolcall_endcontentIndex, toolCall ← 完整的 ToolCall
start / done / errorprovider 层事件;正常 agent loop 会把它们翻译成 message_start/message_end,一般见不到

规则只有一句:用 contentIndex 认块;delta 拿来实时显示,一旦对应的 *_end 到了就用它覆盖;message_end.message 到了就整条覆盖。任何「自己算出来的最终文本」都是错的,权威版本在 end 里。

6 · agent_end 不等于「结束了」

这是最容易写错的一处。

prompt response        # 只代表:命令被接受 / 入队 / 立刻处理了
  ↓
agent_end              # 一次低层 run 结束
  ↓                    # 但自动重试 / 溢出恢复 / 压缩 / steer / follow-up 还能继续
agent_settled          # 这里才是「pi 不会再自己动了」

要「跑完」的语义,就等 agent_settled。上面那条最小的两次字母回复里,第 14、15 行正好说明了这对:agent_end 之后紧接着才是 agent_settled。

还有个竞态

事件是异步发的:先订阅,再发 prompt,否则回复快到在你装 listener 之前就 agent_settled 了。官方 RpcClient.promptAndWait() 内部就是按这个顺序做的;如果你分开调 prompt() + waitForIdle(),记得 waitForIdle() 只在确实有 run 在跑时调用。

7 · RPC:把单向流变成双向控制

pi --mode rpc --no-session

普通 CLI 参数照常生效(--provider、--model、--session-dir、--name…)。唯一被明确拒绝的是 @file 形式的 prompt 参数—— prompt 得走 prompt 命令。

协议一共四类记录:

方向记录作用
stdinCommand发 prompt、查状态、换模型、管会话
stdoutresponse某条命令成功与否 + 数据
stdoutSession event第 4 节那一整套
双向Extension UI record扩展的对话框/通知子协议

用 id 关联,不要按顺序猜

命令处理是异步的,响应顺序不保证。给每条可能并发的命令一个字符串 id,对应 response 会原样回显:

{"id":"req-1","type":"get_state"}
{"id":"req-1","type":"response","command":"get_state","success":true,"data":{…}}

事件一般没有 id(它描述会话,不描述某次请求);例外是 bash_execution_update 会带上发起命令的 id,以及 extension_ui_response 会带回 extension_ui_request 的 id(它不产生普通 response)。

这是我从真进程里拿回来的 get_state 响应(截断):

{"id":"r1","type":"response","command":"get_state","success":true,"data":{
  "model":{"id":"deepseek-v4-pro","provider":"deepseek","api":"openai-completions",
           "contextWindow":1000000,"maxTokens":384000,"cost":{…}, …},
  "thinkingLevel":"high",
  "isStreaming":false,"isCompacting":false,
  "steeringMode":"one-at-a-time","followUpMode":"one-at-a-time",
  "sessionId":"01a0dccb-5694-71b5-9605-37fc1730a673",
  "autoCompactionEnabled":true,
  "messageCount":0,"pendingMessageCount":0}}

注意 no-session 下 sessionFile 就不出现,而 sessionId 仍然有;model 是完整对象,不是字符串。

prompt 及其并发语义

{"id":"req-2","type":"prompt","message":"Review this repository"}
{"id":"req-2","type":"response","command":"prompt","success":true}

命令分组速查

组命令
Promptingprompt · steer · follow_up · abort · clear_queue · new_session
Stateget_state · get_messages
Model / Thinkingset_model · cycle_model · get_available_models · set_thinking_level · cycle_thinking_level · get_available_thinking_levels
Queue / 压缩 / 重试set_steering_mode · set_follow_up_mode · compact · set_auto_compaction · set_auto_retry · abort_retry
Bashbash · abort_bash
Sessionget_session_stats · export_html · switch_session · fork · clone · get_fork_messages · get_entries · get_tree · get_last_assistant_text · set_session_name
自省get_commands(列出当前可用的扩展/模板命令)

abort 会等会话真的 idle 再回 response;要做 TUI 里 Esc 那种「清空队列 + 打断 + 把文本还给输入框」,先 clear_queue(它会返回被清掉的 steering/followUp 文本),再 abort。

8 · 最小客户端

Python 版(官方示例,注意用的是二进制管道,天然只按 LF 切):

import json, subprocess

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

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

TypeScript 侧不必手写——包里导出了 RpcClient,它负责起子进程、关联 id、给类型化的命令方法和事件监听:

import { RpcClient } from "@earendil-works/pi-coding-agent";

const client = new RpcClient({ cliPath: "../dist/cli.js", args: ["--no-session"] });

const unsubscribe = client.onEvent((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  } else if (event.type === "tool_execution_start") {
    process.stderr.write(`\n[tool: ${event.toolName}]\n`);
  }
});

try {
  await client.start();
  await client.promptAndWait("Explain this repository");   // 先订阅后发,无竞态
} finally {
  unsubscribe();
  await client.stop();
}

仓库里的 examples/rpc-client.ts 就是这个;它指向 dist/cli.js,所以从 checkout 跑要先 build 包。

9 · 错误与关闭

10 · 一页速记

· 相关

出处

pi 0.87.1 自带文档:docs/cli-integration.md、docs/rpc.md、docs/rpc-commands.md、docs/json.md、docs/rpc-extension-ui.md(源码仓库 earendil-works/pi)。第 2 节与第 7 节的 JSON 是本机实跑抓取。协议类型定义看 packages/coding-agent/src/modes/rpc/rpc-types.ts 与 json-event.ts。