跳到主内容
创建时间:最后更新:作者:白川

进程外集成:Print、JSON 与 RPC

从一次性问答到长驻进程的三种非交互式接法

Pi 有三种非交互式的接法,它们不是「同一个东西的三种输出格式」,而是三种不同的进程模型:

接法进程形态你能拿到什么你能改变什么
print
跑完就退
最终一段文本
启动前的一切
JSON
跑完就退
一次运行的结构化事件流
启动前的一切
RPC
常驻
事件流 + 命令响应
运行途中的模型、会话、队列

选择依据只有一条:你要在运行途中改变 Pi 的状态吗? 不需要就 print 或 JSON,需要就 RPC。

本文是 安装与运行模式 的下半篇。上一篇讲的是怎么把 Pi 装起来、四种模式怎么选;这篇讲的是选完之后,协议到底怎么跑,以及每一条会在什么地方坑你。

三种接法共用同一套 agent、会话、资源和工具。模式只决定三件事:输入怎么进来、输出怎么暴露、进程是否继续留着接受更多命令。


12.1 print 模式

一句话结论

print 模式是一次性问答:给一句提示词,拿一段最终文本,进程退出。它不暴露任何中间过程。

三种进入方式

bash
pi --print "Summarize the changes in this repository"
pi -p "Summarize the changes in this repository"

--print 和 -p 是同一个开关。第三种进入方式是被动的:stdin 或 stdout 任一不是 TTY,Pi 就静默进入 print 模式(这一点在上一篇讲过,resolveAppMode 里 parsed.print || !stdinIsTTY || !stdoutIsTTY 这一行的直接后果)。

这条自动规则有个副作用值得记住:pi > out.txt 会让 Pi 处理完就退出,你以为自己在开交互式界面,实际上它根本没进 TUI。

还有一种容易混的情况:--mode text 不是 print 模式的别名。 --mode text 只选输出形态,不选生命周期;stdin/stdout 都是终端时它仍然开 TUI。要「文本输出 + 跑一次就退」,用 --print。

输出契约

print 模式只做三件事:

  1. 跑完你给的提示词;
  2. 把最终 assistant 消息里的文本块写到 stdout;
  3. 退出。

中间的事件、工具调用、思考过程一概不暴露。如果你的脚本需要知道「Pi 干了哪些工具」,print 模式给不了,换 JSON 模式。

一个实现细节决定了 stdout 的内容边界:print 模式取的是 state.messages 的最后一条消息,而且只在它是 assistant 消息时才打印文本。如果最后一条是别的角色(比如你连着给了多个提示词,最后一次还在跑),stdout 会是空的。

退出码是有语义的

这是 print 模式最值得写进脚本的部分:

情况stdoutstderr退出码
正常完成
最终文本
——
0
assistant 以 error 或 aborted 结束
——
errorMessage 或 Request <stopReason>
1
撞上模型输出上限(length)
已有的部分文本照样打印
Response was truncated: the model reached its output token limit.
1

第三行是重点:被截断的响应仍然把部分文本打到 stdout。这让「先拿个大概,够不够再调模型重试」的写法可行,但你必须同时看退出码,否则会把半截输出当成完整答案。

另外两种失败会直接走到 catch,错误信息写 stderr,退出码同样是 1:

typescript
try {
	// ...
} catch (error: unknown) {
	console.error(error instanceof Error ? error.message : String(error));
	return 1;
}

这一点和 RPC 模式正好相反,值得单独记一笔。

print 模式的初始消息允许包含 @file,由 Pi 展开成文件内容后再送进会话。RPC 模式拒绝 @file 参数,要用 prompt 命令送。

扩展看到的 mode

print 模式绑定扩展时传的 mode 是 "print":

typescript
await session.bindExtensions({
	mode: mode === "json" ? "json" : "print",
	// ...
});

ExtensionMode 一共四个取值:"tui"、"rpc"、"json"、"print"。扩展如果按模式分支渲染,print 模式只有最基础的一套能力可用——它既没有终端 UI,也没有事件流订阅者。

什么时候用 print

场景用哪个
CI 里跑一次检查,只要最终结论
print
需要知道 Pi 用了哪些工具、花了多少 token
JSON
要在运行途中换模型、重试、改方向
RPC
需要把 Pi 的输出接进管道做后处理
print

12.2 JSON 事件流

一句话结论

JSON 模式是一次运行的结构化回放:所有提示词在进程启动时一次性给完,Pi 把整段过程写成 JSONL 事件流,然后退出。

它和 print 模式的区别不在「输出是不是机器可读」,而在中间过程可见。

bash
pi --mode json "Review this repository" > events.jsonl

帧协议:严格 JSONL

先说最容易被忽略、但一旦踩了就极难排查的一条:记录边界只有一个——LF(\n)。

规则说明
一条记录 = 一个 JSON 对象 + 一个 LF
不能有跨行的对象
只在 LF 上切分
U+2028 / U+2029 不是记录边界
允许可选的前导 CR
切完之后去掉一个尾部的 \r,兼容 CRLF 输入
stdout 被 JSONL 独占
诊断信息和应用日志走 stderr
必须持续读取 stdout
不读会撑满管道缓冲区,把 Pi 卡住

U+2028 / U+2029 这两个字符是 Unicode 的行分隔符和段分隔符,在 JSON 字符串里是完全合法的。而 Node.js 的 readline 除了 \n 还会认这两个。

所以:

typescript
// 错的
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdout });
rl.on("line", (line) => { /* ... */ });
typescript
// 对的:用 UTF-8 流解码器,自己按 LF 切
import { StringDecoder } from "node:string_decoder";

const decoder = new StringDecoder("utf8");
let buffer = "";

process.stdout.on("data", (chunk: Buffer) => {
	buffer += decoder.write(chunk);
	while (true) {
		const i = buffer.indexOf("\n");
		if (i === -1) return;
		const line = buffer.slice(0, i).replace(/\r$/, "");
		buffer = buffer.slice(i + 1);
		handle(line);
	}
});

Pi 自己的实现就在 src/modes/rpc/jsonl.ts,函数叫 attachJsonlLineReader,注释里写得很直白:

This intentionally does not use Node readline. Readline splits on additional Unicode separators that are valid inside JSON strings and therefore does not implement strict JSONL framing.

如果你用 TypeScript,别手搓——用导出的 RpcClient(见 12.3)。

第一条记录是会话头

JSON 模式的第一条记录是当前会话的头:

json
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path"}

字段对应 SessionHeader 接口:type 固定是 "session",version 是会话文件格式版本(v1 的老会话没有这个字段,所以它是可选的),id 是会话 ID,timestamp 是 ISO 8601 字符串,cwd 是工作目录。

注意 timestamp 的类型——这里是 ISO 字符串,而消息里的 timestamp 是毫秒时间戳。这是 12.6 那一节要专门讲的双时间戳陷阱,这里先记住。

RPC 模式不发这条记录。 它是双向长驻协议,没有「开头」这个概念。需要会话 ID 和文件路径就用 get_state 命令。

事件全表

一次典型的运行会产生这样一串记录:

json
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":"Review this repository","timestamp":1733234401000}}
{"type":"message_end","message":{"role":"user","content":"Review this repository","timestamp":1733234401000}}
{"type":"message_start","message":{"role":"assistant","content":[],"stopReason":"pending","...":"..."}}
{"type":"message_update","usage":{"...":"..."},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_end","message":{"role":"assistant","...":"..."}}
{"type":"turn_end","message":{"role":"assistant","...":"..."},"toolResults":[]}
{"type":"agent_end","messages":[{"...":"..."}],"willRetry":false}
{"type":"agent_settled","aborted":false}

message_start / message_update / message_end 这一组是同一条消息的三个阶段,user 消息只有 start 和 end,assistant 消息才有中间的 update。

按职责分成五组:

事件字段含义
agent_start
——
一次底层 agent 运行开始
agent_end
messages、willRetry
那次底层运行结束
agent_settled
aborted
Pi 不会自动继续了
turn_start
——
一个 assistant 回合开始
turn_end
message、toolResults
一个 assistant 回复和它产生的工具调用结束
message_start
message
一条消息开始
message_update
usage、assistantMessageEvent
assistant 消息的某个内容块更新了
message_end
message
一条消息完成,这是权威结果
tool_execution_start
toolCallId、toolName、args
工具执行开始
tool_execution_update
toolCallId、toolName、args、partialResult
工具报告了部分结果
tool_execution_end
toolCallId、toolName、result、isError、durationMs
工具执行结束
queue_update
steering、followUp
待处理的 steering / follow-up 队列变了
entry_appended
entry
扩展通过 pi.appendEntry() 追加了自定义会话条目
session_info_changed
name
会话显示名变了,缺失表示被清空
thinking_level_changed
level
思考等级变了
compaction_start
reason
压缩开始
compaction_end
reason、result、aborted、willRetry
压缩结束
auto_retry_start
attempt、maxAttempts、delayMs、errorMessage
assistant 回合开始自动重试
auto_retry_end
success、attempt、finalError?
自动重试结束
summarization_retry_scheduled
attempt、maxAttempts、delayMs、errorMessage
压缩/摘要要重试
summarization_retry_attempt_start
source、reason?
重试开始
summarization_retry_finished
——
重试结束
bash_execution_update
id?、delta
仅 RPC:shell 输出流
extension_error
extensionPath、event、error
仅 RPC:扩展处理器抛异常

agent_end 不是「跑完了」

这是 JSON 模式(和 RPC 模式)里第二个最容易写错的地方。

agent_end 关闭的是一次底层 agent 运行,不是整个会话级运行。 后面还可能跟着:

  • 自动重试(auto_retry_start / auto_retry_end)
  • 溢出恢复(compaction_end 里 willRetry: true,然后 Pi 重试提示词)
  • 压缩重试
  • steering 队列里排着的消息
  • follow-up 队列里排着的消息

判断「这次真的跑完了」要看 agent_settled。 它的语义是「Pi 不会自动继续了」。

json
{"type":"agent_settled","aborted":false}

aborted 为 true 表示这次是因为被中止而结束的。

一个具体的区分:agent_end 带 willRetry 字段,直接告诉你「马上会有一次重试」;agent_settled 没有这种预告,它只告诉你「到此为止了」。

message_update 在传输层被剥掉了累积快照

这条是 JSON 模式的第三个坑,也是写流式客户端时最容易搞错的地方。

SDK 内部的 AgentSessionEvent 里,message_update 携带累积快照——每个增量事件都带一份「到目前为止的完整消息」。这对进程内消费很方便,但对 wire 协议是灾难:一条 8000 token 的回复会产生 8000 条各带 8000 token 快照的记录,流量是平方级的。

所以 JSON 和 RPC 在传输前会做一次转换,源码在 src/modes/json-event.ts:

typescript
export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent {
	if (event.type !== "message_update") {
		return event;
	}
	if (event.message.role !== "assistant") {
		throw new Error("message_update message is not an assistant message");
	}

	return {
		type: "message_update",
		usage: event.message.usage,
		assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent),
	};
}

转换做两件事:

  1. 删掉 assistantMessageEvent.partial。 那个字段是「到目前为止的完整 assistant 消息」,只有累积语义,没有增量语义。partial 被剥掉后,流大小回到线性。
  2. 顶层 usage 保留。 它是供应商上报的累计用量,体积恒定,剥了就没法算成本。

顺带一个副作用:toolcall_start 的 partial 里藏着工具调用的 id 和 name,剥掉 partial 就丢了这两个值。所以转换函数专门给 toolcall_start 补了回来:

typescript
if (event.type === "toolcall_start") {
	const toolCall = event.partial.content[event.contentIndex];
	if (toolCall?.type !== "toolCall") {
		throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`);
	}
	const { partial: _partial, ...deltaEvent } = event;
	return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name };
}

所以线上拿到的 toolcall_start 多了 id 和 toolName 两个字段,而 SDK 里没有。写类型定义时别照抄 SDK 的类型。

用 contentIndex 关联,靠三个事件定界

剥掉 partial 之后,客户端靠什么把增量拼成完整消息?

靠 contentIndex。 每个内容块有一个下标,增量事件都带着它:

嵌套事件类型除 type 外的字段含义
start
——
供应商流开始
text_start
contentIndex
文本块开始
text_delta
contentIndex、delta
往文本块追加
text_end
contentIndex、content
文本块结束,带权威内容
thinking_start
contentIndex
思考块开始
thinking_delta
contentIndex、delta
往思考块追加
thinking_end
contentIndex、content
思考块结束,带权威内容
toolcall_start
contentIndex、id、toolName
工具调用块开始
toolcall_delta
contentIndex、delta
追加序列化后的参数片段
toolcall_end
contentIndex、toolCall
工具调用结束,带完整 ToolCall
done
reason、message
供应商流正常结束
error
reason、error
供应商流出错或中止

一共 12 种,和 packages/ai/src/types.ts 里的 AssistantMessageEvent 联合类型一一对应。

定界的规矩是三条:

事件你该做什么
text_delta / thinking_delta / toolcall_delta
追加到 contentIndex 对应的缓冲区
text_end / thinking_end / toolcall_end
用这里的权威内容替换掉你拼出来的结果
message_end
用 message_end.message 替换整条消息

「替换」而不是「追加」是关键。text_end 带的 content 是供应商最终确认的文本,和 delta 累加的结果可能不完全一致(供应商可能做后处理)。如果你的客户端只在 message_end 替换整条消息,中间那两个 *_end 的权威内容就浪费了。

顺带一提:start、done、error 这三种嵌套事件在正常的 agent 循环里不会以 message_update 的形式出现——agent 循环把供应商层的 start / done / error 翻译成了 message_start 和 message_end 两个会话事件。导出类型 JsonAgentSessionEvent 仍然接受它们,是为了兼容手工构造会话事件的调用方。

顶层 usage 可能是 0

message_update 顶层的 usage 是「本次 assistant 回复的最新累计用量」。有些供应商在流式过程中不报用量,所以在回复完成之前它可能一直是 0。

json
{"type":"message_update","usage":{"input":100,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":101,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello "}}

想做实时成本显示的话,别用流式过程中的 usage,等 message_end。

工具执行事件

工具的生命周期用 toolCallId 关联:

json
{"type":"tool_execution_start","toolCallId":"call_abc123","toolName":"bash","args":{"command":"ls -la"}}
{"type":"tool_execution_update","toolCallId":"call_abc123","toolName":"bash","args":{"command":"ls -la"},"partialResult":{"content":[{"type":"text","text":"partial output"}],"details":{}}}
{"type":"tool_execution_end","toolCallId":"call_abc123","toolName":"bash","result":{"content":[{"type":"text","text":"complete output"}],"details":{}},"isError":false}

durationMs 是工具 execute() 真正花掉的墙钟时间,用单调时钟测量——工具没跑起来的时候这个字段缺失。

partialResult 是工具给的最新部分结果。它是替换还是追加,取决于那个工具自己的结果契约,协议不替工具做主。内置的 bash 是追加(流式输出片段),别的工具可能给完整快照。

压缩事件

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

reason 有三个取值:"manual"、"threshold"、"overflow"。

成功时:

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

三种失败形态要分清:

情况resultaborted其它
成功
有
false
willRetry 视情况
被中止
缺失
true
——
失败
缺失
false
errorMessage 描述原因

溢出恢复成功时 willRetry 是 true,Pi 随后会重试那条提示词。客户端看到 compaction_end 且 willRetry: true,不要当成结束。

重试事件

assistant 回合的自动重试:

json
{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"delayMs":2000,"errorMessage":"529 overloaded"}
{"type":"auto_retry_end","success":true,"attempt":2}

最终失败时 auto_retry_end 带 success: false 和一个 finalError 字符串。

压缩和分支摘要的重试是另一组事件:

json
{"type":"summarization_retry_scheduled","attempt":1,"maxAttempts":3,"delayMs":2000,"errorMessage":"terminated"}
{"type":"summarization_retry_attempt_start","source":"compaction","reason":"threshold"}
{"type":"summarization_retry_finished"}

分支摘要的 source 是 "branchSummary",且没有 reason 字段;压缩重试的 source 是 "compaction",reason 取 "manual" / "threshold" / "overflow"。

退出码

JSON 模式下,一次失败或被中止的 assistant 响应出现在事件流里,但本身不产生非 0 退出码。 成功与否要自己从事件里判断。

只有两种情况退出码非 0:调用过程抛异常(catch 分支返回 1),以及进程被信号打断。

这和 print 模式正相反——print 模式会把 error / aborted 转成退出码 1。所以从 print 迁到 JSON 时,「用退出码判断成功」的逻辑必须改掉。

一行 jq 就能用

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

2>/dev/null 是必要的:stderr 上的诊断信息会混进管道。


12.3 RPC 模式

一句话结论

RPC 模式是受控的常驻进程:Pi 保持运行,你从 stdin 发命令,从 stdout 收响应和事件。

bash
pi --mode rpc --no-session

其它命令行选项照常生效,用来选工作目录、模型、工具、资源和会话行为:--provider、--model、--name、--no-session、--session-dir。

RPC 模式拒绝 @file 提示词参数,提示词要用 prompt 命令送。

四类记录

方向记录用途
stdin
命令
让 Pi 提示词、查状态、改配置、管会话
stdout
response
报告某条命令成没成,带上返回数据
stdout
会话事件
流式报告运行、消息、工具、队列、压缩、重试
双向
扩展 UI 记录
在 Pi 和客户端之间转发扩展交互

JSON 模式和 RPC 模式共享同一套会话事件形状,区别是:JSON 模式多一条会话头(因为它有明确的开始),RPC 模式没有(双向协议没有「开头」)。

帧协议:还是严格 JSONL

RPC 的帧规则和 JSON 模式完全一样:一个 LF 结尾的对象一行。

不要用 Node 的 readline。 它会在 U+2028 / U+2029 处切分,而这两个字符在 JSON 字符串里合法。Pi 官方文档在这一条上重复了三遍——docs/json.md、docs/rpc.md、以及 src/modes/rpc/jsonl.ts 的注释——因为这是实际踩坑最多的地方。

除了 LF 切分,RPC 还有几条要求:

要求原因
持续读取 stdout
管道缓冲区满了会把 Pi 卡住;Pi 尊重 stdout 背压,但客户端不读它就会停
写命令时尊重 stdin 背压
同理
stdout 专供协议记录
诊断和日志走 stderr
不要把 stderr 当协议解析
它是给人看的文本

用 id 关联,不要靠顺序

每条命令都可以带一个可选的字符串 id,匹配到的响应会原样回显这个 id:

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

命令处理是异步的,所以必须按 ID 关联,不能靠响应顺序。 多条命令同时在飞的时候,顺序不保证。

会话事件一般没有命令 ID,因为它们描述的是会话活动而不是某次请求。有一个例外:bash_execution_update——当发起它的 bash 命令带了 id 时,输出事件会重复这个 ID。

扩展 UI 的响应用的是 extension_ui_request 给的 ID,它不产生常规命令响应。

生命周期:success 只代表「已接受」

这是 RPC 部分最容易写错、后果也最严重的一条。

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

一次成功的 prompt 响应,意味着提示词被接受、排队或处理了。它不代表模型的工作完成了。

data.disposition 有三个取值:

disposition含义你该怎么等
"started"
Pi 接受了它,开始跑
等 agent_settled
"queued"
Pi 在运行中,把它排进了队列
等当前运行结束 + 队列排空
"handled"
扩展命令或 input 处理器消费了它,根本没起 run
不要等 agent_settled

"handled" 这一档特别重要:如果提示词被扩展吃掉了,压根不会有运行,也就不会有 agent_settled 事件。等它会一直等到超时。

为什么响应会这么早?看 src/modes/rpc/rpc-mode.ts 里 prompt 的实现:

typescript
case "prompt": {
	let preflightSucceeded = false;
	void session
		.prompt(command.message, {
			images: command.images,
			streamingBehavior: command.streamingBehavior,
			source: "rpc",
			preflightResult: (disposition) => {
				preflightSucceeded = true;
				output(success(id, "prompt", { disposition }));
			},
		})
		.catch((e) => {
			if (!preflightSucceeded) {
				output(error(id, "prompt", e.message));
			}
		});
	return undefined;
}

响应是从 session.prompt() 内部的一个「前置检查完成」回调里发出的。而这个回调在 _startRun 里的位置是:

typescript
messages = await this._prepareRun(trigger);
// ...
onStarted?.();          // ← preflightResult 在这里被调用

await this.agent.prompt(messages);   // ← 模型还没被调用

也就是说,success: true 的响应是在模型被调用之前就发出的。 它确认的是「Pi 收下了这条提示词」,不是「Pi 答完了」。

对应地,success: false 也有明确含义:提示词在被接受之前就被拒绝了。 拒绝的原因有几种,源码里抛异常的位置包括:

  • 运行中没给 streamingBehavior: Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.
  • 没有选模型
  • 没有配置凭据(或 OAuth 凭据过期)

这些异常的 message 会原样进 error 字段。

接受之后的失败不会再来第二条 response。 它们出现在消息流和事件流里。这就是为什么必须继续读事件。

订阅要赶在提示词前面

事件流是异步的。如果你先发 prompt 再注册监听器,一次跑得很快的任务可能在你订阅之前就把事件吐完了。

官方客户端的解法是 promptAndWait(),它内部先装监听器再发提示词:

typescript
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<JsonAgentSessionEvent[]> {
	const eventsPromise = this.collectEvents(timeout);
	await this.prompt(message, images);
	return eventsPromise;
}

如果你把 prompt() 和 waitForIdle() 分开调用,顺序必须是:先装事件监听器 → 再 prompt() → 只在有 run 在跑的时候调 waitForIdle()。

错误

失败的命令返回一条 success: false 的响应:

json
{"id":"req-3","type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}

JSON 解析失败返回一条没有请求 ID 的响应,command 是 "parse":

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

还有几类错误不在协议里,客户端必须自己处理:

  • 子进程启动失败
  • 意外退出
  • stderr 上的诊断信息
  • 取消
  • 自己的超时

成功响应只覆盖命令处理本身。 提示词被接受之后的供应商失败和中止,出现在消息和事件流里。

关闭:关掉 stdin

关闭子进程的 stdin 就是请求有序关闭。 Pi 会先 dispose 掉当前的 runtime 再退出。

python
process.stdin.close()
process.wait()

客户端仍然要自己处理信号和意外退出。

Pi 自己也注册了信号处理器:

信号行为
SIGTERM
杀掉被跟踪的分离子进程,dispose runtime,退出码 143
SIGHUP
同上,退出码 129(Windows 上不注册)

另外,扩展可以通过扩展上下文请求关闭。Pi 会在当前命令处理完、或者当前运行发出 agent_settled 之后完成关闭。

扩展也能请求关闭:bindExtensions 的 shutdownHandler 回调里置一个标志,Pi 在每次 agent_settled 事件后检查它。

RpcClient:不要手搓协议

如果你的客户端是 TypeScript 或 Node.js,用导出的 RpcClient,不要自己实现 JSONL 帧。

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

const client = new RpcClient({
	cliPath: join(exampleDirectory, "../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(prompt);
	process.stdout.write("\n");
} finally {
	unsubscribe();
	await client.stop();
}

RpcClient 的构造选项:

选项说明
cliPath
CLI 入口路径,默认 "dist/cli.js"
cwd
子进程工作目录
env
追加到 process.env 的环境变量
provider
传给 --provider
model
传给 --model
args
追加的额外 CLI 参数

它内部做的事:启动 node <cliPath> --mode rpc ... 子进程、用严格 JSONL 读 stdout、按 ID 关联请求和响应、把事件投递给监听器。

几个值得知道的实现细节:

行为值
请求 ID 格式
req_1、req_2…… 自增
单条命令的响应超时
30 秒
waitForIdle() / collectEvents() 默认超时
60 秒
start() 后的启动等待
100 毫秒
stop() 的终止序列
先 SIGTERM,1 秒后还没退就 SIGKILL

RpcClient 的方法名基本就是命令名的 camelCase 版本(prompt()、steer()、getState()、setModel()、compact()、bash()、getEntries()……一一对应上一节的 33 条命令)。

注意 prompt() 的返回值是 PromptDisposition,不是「完成结果」——它返回的就是 data.disposition。

RpcClient 需要一条可运行的 Pi CLI 路径。 仓库里的示例指向 dist/cli.js,所以从 checkout 跑示例前得先 build。

用什么语言写客户端都行

官方文档给的最小示例是 Python,用二进制管道读(按 LF 切,不把 Unicode 分隔符当边界):

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

RPC 还是 SDK

SDKRPC
进程边界
没有
子进程
控制模型
直接的 TypeScript 方法和事件
JSONL 命令、响应、事件
适合
Node.js / Bun 宿主,要完整 API
其它语言、隔离进程、IDE、自建客户端

已经在 Node 里、想要直接的类型访问,就用 SDK 嵌入(下一篇讲)。需要跨进程、语言无关、或者要隔离,就用 RPC。


12.4 RPC 命令表

一共 33 条命令。全部接受可选的字符串 id,匹配的响应会回显它。

全表

#命令参数返回
1
prompt
message、images?、streamingBehavior?
{disposition}
2
steer
message、images?
{disposition}
3
follow_up
message、images?
{disposition}
4
abort
——
——
5
clear_queue
——
{steering, followUp}
6
new_session
parentSession?
{cancelled}
7
get_state
——
RpcSessionState
8
get_messages
——
{messages}
9
set_model
provider、modelId
完整 Model 对象
10
cycle_model
——
{model, thinkingLevel, isScoped} 或 null
11
get_available_models
——
{models}
12
set_thinking_level
level
——
13
cycle_thinking_level
——
{level} 或 null
14
get_available_thinking_levels
——
{levels}
15
set_steering_mode
mode
——
16
set_follow_up_mode
mode
——
17
compact
customInstructions?
CompactionResult
18
set_auto_compaction
enabled
——
19
set_auto_retry
enabled
——
20
abort_retry
——
——
21
bash
command、excludeFromContext?
BashResult
22
abort_bash
——
——
23
get_session_stats
——
SessionStats
24
export_html
outputPath?
{path}
25
switch_session
sessionPath
{cancelled}
26
fork
entryId
{text, cancelled}
27
clone
——
{cancelled}
28
get_fork_messages
——
{messages}
29
get_entries
since?
{entries, leafId}
30
get_tree
——
{tree, leafId}
31
get_last_assistant_text
——
{text}
32
set_session_name
name
——
33
get_commands
——
{commands}

下面挑几条语义不显然的展开讲。

prompt:唯一需要 streamingBehavior 的命令

运行中再发 prompt 而不给 streamingBehavior,命令会返回错误:

json
{"type":"prompt","message":"New instruction","streamingBehavior":"steer"}
streamingBehavior投递时机
"steer"
当前 assistant 回合的工具调用执行完之后、下一次 LLM 调用之前
"followUp"
等 agent 彻底停下来之后才投递

两个例外:

  • 扩展命令(比如 /mycommand)即使在运行中也会立即执行,不需要 streamingBehavior。扩展命令自己管理 LLM 交互(通过 pi.sendMessage())。
  • skill 命令(/skill:name)和提示词模板(/template)在发送/排队前会被展开。

prompt 的响应:

json
{"id":"req-1","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}

success: true 意味着提示词被接受、排队或立即处理了;success: false 意味着在接受之前就被拒绝。接受之后的失败不会为同一个 request id 再发第二条 response。

steer 和 follow_up 的 data.disposition 只有两个取值:"handled"(被 input 处理器消费了)和 "queued"(排进队列了,包括被处理器改写之后排队的)。它不保证这条消息还留在队列里。

abort 与 clear_queue

abort 会中止当前操作,并等到会话变成空闲之后才响应。

clear_queue 清空 steering 和 follow-up 队列,并返回它们的文本:

json
{
  "type": "response",
  "command": "clear_queue",
  "success": true,
  "data": {
    "steering": ["Change direction"],
    "followUp": ["Summarize when finished"]
  }
}

实现交互式模式的 Esc 行为时,先 clear_queue 再 abort,然后把返回的文本塞回客户端的编辑器。反过来不行:abort 在队列里的消息还在时会继续它们。

bash:输出在下一次提示词才到达模型

bash 在运行期间会流出 bash_execution_update 事件,响应里是最终结果:

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

输出被截断时多一个 fullOutputPath(形如 /tmp/pi-bash-abc123.log),truncated 变成 true。

给 bash 命令带上 id,流式的 bash_execution_update 事件就会重复这个 ID,这样你能把输出和命令对上。

excludeFromContext: true 表示命令输出仍然存进会话文件,但在下一次提示词时不进模型上下文。

最容易搞错的一点:bash 结果不是立刻到模型的。

bash 命令立即执行并返回 BashResult。内部会创建一条 BashExecutionMessage 存进 agent 的消息状态。下一次 prompt 命令发出时,Pi 在把上下文消息转给模型之前做一次转换:除非 excludeFromContext 是 true,BashExecutionMessage 会变成一条 user 消息:

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

所以:

  1. bash 输出在「下一次提示词」才到达模型,不是立刻。
  2. 一次提示词之前可以跑多条 bash 命令,Pi 会把每一条没设 excludeFromContext 的输出都带上。

转换函数在 src/core/messages.ts 的 bashExecutionToText(),它还会在末尾追加退出码、取消标记和截断提示。

get_entries:可以当持久游标用

json
{"type":"get_entries","since":"abc123"}

since 是一个条目 ID。传了它就只返回严格在它之后的条目。会话是带稳定 ID 的追加式条目树,所以条目 ID 可以当持久游标用——客户端重启了也能续上。

since 匹配不到任何条目 ID 时,响应是 success: false。

返回里还有一个 leafId,是当前叶子条目的 ID(空会话是 null)。客户端一次往返就能知道活动分支有没有移动。

get_tree 返回 {tree, leafId},每个节点是 {entry, children, label?, labelTimestamp?}。tree 是数组而不是对象,因为导航 API 可能造出多个根,父链断掉的孤儿条目也会作为根出现。

get_messages 和 get_entries 的区别值得记一笔:get_entries 包含压缩前的历史和被放弃的分支,get_messages 不含。

get_session_stats:压缩后有两个 null

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 和 cost 覆盖整个会话,包括 assistant 消息、工具上报的用量、以及压缩和分支摘要的生成开销。

contextUsage 在没有模型或拿不到上下文窗口时整个字段缺失。 刚压缩完的那一刻,contextUsage.tokens 和 contextUsage.percent 是 null,直到有一条新的 assistant 回复提供有效用量。

get_commands:可发现命令

json
{
  "type": "response",
  "command": "get_commands",
  "success": true,
  "data": {
    "commands": [
      {
        "name": "fix-tests",
        "description": "Fix failing tests",
        "source": "prompt",
        "sourceInfo": {
          "path": "/home/user/myproject/.pi/agent/prompts/fix-tests.md",
          "source": "local",
          "scope": "project",
          "origin": "top-level"
        }
      }
    ]
  }
}

每条命令的字段:

字段说明
name
命令名,用的时候加 / 前缀
description
可选,扩展命令可能没有
source
"extension" / "prompt" / "skill" 三选一
sourceInfo.path
资源的绝对路径
sourceInfo.source
Pi 怎么发现的它,比如 "local"、"auto"、"cli"
sourceInfo.scope
"user" / "project" / "temporary"
sourceInfo.origin
"top-level"(直接加载)或 "package"(包资源)
sourceInfo.baseDir
包基目录,包资源才有

skill 的 name 带 skill: 前缀。

内置的 TUI 命令(/settings、/hotkeys 这些)不在列表里。 它们只在交互式模式下处理,通过 prompt 发过去也执行不了。

其余命令的细节

get_state 的返回是一个扁平的状态对象:

json
{
  "type": "response", "command": "get_state", "success": true,
  "data": {
    "model": {...}, "thinkingLevel": "medium",
    "isStreaming": false, "isCompacting": 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
  }
}

model 字段是完整的 Model 对象,没选模型时这个字段直接缺失(不是 null)。sessionName 同理——没设置就缺失。

set_model 找不到模型时返回 success: false,错误信息是 Model not found: <provider>/<modelId>。Model 对象包含 id、name、api、provider、baseUrl、reasoning、input、contextWindow、maxTokens 和 cost,cost 的单位是美元 / 每百万 token。

set_thinking_level 的 level 有 7 个取值:"off"、"minimal"、"low"、"medium"、"high"、"xhigh"、"max"。"xhigh" 和 "max" 只有在选中的模型支持时才会暴露,有些模型两个都支持。get_available_thinking_levels 对不支持推理的模型返回 ["off"]。

set_steering_mode 和 set_follow_up_mode 的 mode 只有 "all" 和 "one-at-a-time" 两个取值,默认是 "one-at-a-time"。"all" 是当前回合结束后把所有消息一次性投递,"one-at-a-time" 是每个完成的回合投递一条。

compact 的返回里 estimatedTokensAfter 是启发式估算,基于压缩后立刻重建的消息上下文,不是供应商精确的 token 数。usage 报告的是生成摘要的那次(或那几次)LLM 调用,自定义压缩处理器可能不填它。

new_session、switch_session、fork、clone 都能被扩展取消(session_before_switch / session_before_fork),取消时响应仍然是 success: true,但 data.cancelled 是 true。fork 额外返回被分叉的那条消息的文本。set_session_name 的名字不能是空白,空白会返回错误 Session name cannot be empty。


12.5 扩展 UI 子协议

一句话结论

扩展通过 ctx.ui 请求用户交互。在 RPC 模式下,这些调用变成一套与常规命令并列的请求-响应子协议。

两类方法

类别方法行为
对话框
select、confirm、input、editor
发出 extension_ui_request,阻塞直到客户端回 extension_ui_response
即发即忘
notify、setStatus、setWidget、setTitle、set_editor_text
发出 extension_ui_request,不等响应,客户端可以显示也可以忽略

一共 9 个方法,4 个对话框 + 5 个通知。

如果对话框方法带了 timeout 字段,agent 侧会在超时后自动用默认值 resolve。客户端不需要自己计时。

请求长什么样

所有请求都有 type: "extension_ui_request"、一个唯一的 id 和一个 method 字段。

select(完整形状):

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

confirm(完整形状):

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

其余七个方法的字段:

方法字段说明
input
title、placeholder?、timeout?
自由文本输入
editor
title、prefill?
多行编辑器,可预填内容
notify
message、notifyType?
notifyType 取 "info" / "warning" / "error",省略时默认 "info"
setStatus
statusKey、statusText
statusText 传 undefined 或省略表示清掉这个 key
setWidget
widgetKey、widgetLines、widgetPlacement?
widgetPlacement 是 "aboveEditor"(默认)或 "belowEditor";widgetLines 传 undefined 或省略表示清掉
setTitle
title
终端窗口 / 标签页标题
set_editor_text
text
设置输入框里的文本

setWidget 在 RPC 模式下只支持字符串数组,组件工厂会被忽略。

响应怎么回

只有对话框方法需要响应。id 必须和请求对上。

方法响应
select / input / editor
{"type":"extension_ui_response","id":"uuid-1","value":"Allow"}
confirm
{"type":"extension_ui_response","id":"uuid-2","confirmed":true}
任何对话框取消
{"type":"extension_ui_response","id":"uuid-3","cancelled":true}

取消时扩展收到的值是:select / input / editor 收到 undefined,confirm 收到 false。

扩展 UI 响应不产生常规命令响应。 它在 RPC 模式里被单独识别和处理。

RPC 模式下的能力降级

有些 ExtensionUIContext 方法需要直接访问终端,在 RPC 模式下不可用或降级:

方法RPC 模式下的表现
custom()
返回 undefined
onTerminalInput()
返回一个空操作的退订函数
setWorkingMessage()
空操作
setWorkingVisible()
空操作
setWorkingIndicator()
空操作
setHiddenThinkingLabel()
空操作
setFooter()
空操作
setHeader()
空操作
addAutocompleteProvider()
空操作
setEditorComponent()
空操作
setToolsExpanded()
空操作
getEditorText()
返回 ""
getEditorComponent()
返回 undefined
getToolsExpanded()
返回 false
pasteToEditor()
退化成 setEditorText(),没有终端粘贴处理
getAllThemes()
返回 []
getTheme()
返回 undefined
setTheme()
返回 { success: false, error: "Theme switching not supported in RPC mode" }

ctx.mode 在 RPC 模式下是 "rpc",ctx.hasUI 是 true。 因为对话框和即发即忘方法通过扩展 UI 子协议是能用的。

这一条很容易写反:扩展作者看到 hasUI === true 就以为终端能力都在,于是调用 custom() 画组件——结果拿到 undefined。要判断 TUI 专属能力,用 ctx.mode === "tui"。


12.6 消息类型与时间戳陷阱

AgentMessage 是 8 个角色的联合

Pi 的 AgentMessage 联合类型有 8 个角色:

角色用途
system
系统消息:第一条声明初始提示词和工具,后面的可以追加指令、替换提示词段、增删工具
user
用户消息
assistant
助手消息
toolResult
工具结果
bashExecution
直接执行 shell 命令产生的消息(不是 LLM 的工具结果)
custom
扩展发来的上下文消息
branchSummary
从持久化的 branch_summary 条目来的上下文消息
compactionSummary
从持久化的 compaction 条目来的上下文消息

bashExecution 值得单独强调:它是 RPC bash 命令和交互式 ! 命令产生的那种消息,不是模型调用工具的结果。 它会被转成 user 角色的文本再发给模型(convertToLlm 里的 bashExecution 分支)。

custom 和 bashExecution 都有 excludeFromContext 之类的语义差别:custom 的 display 控制终端渲染,details 不发给模型。

在底层的 agent 包里,AgentMessage 的定义更宽,是 Message | CustomAgentMessages[keyof CustomAgentMessages]。应用可以通过 TypeScript 声明合并加自己的角色,所以消费别人宿主传过来的消息时,要容忍未知的自定义角色。

双时间戳

这是最容易踩的一个坑:

出现在哪格式例子
消息里(message.timestamp)
Unix 毫秒时间戳(number)
1733234401000
会话条目上(entry.timestamp)
ISO 8601 字符串
"2024-12-03T14:00:00.000Z"
会话头上(header.timestamp)
ISO 8601 字符串
"2024-12-03T14:00:00.000Z"

会话头是 ISO 字符串,消息是毫秒数。 同一次运行里这两者会同时出现——会话头只有一条,消息有几十条。

写客户端代码时,排序用 entry.timestamp(ISO,可以直接字典序比较),做时间差计算用 message.timestamp(毫秒,可以直接相减)。混用会得到完全错误的结果,而且不会报错。

Usage 的形状

typescript
interface Usage {
  input: number; output: number;
  cacheRead: number; cacheWrite: number; cacheWrite1h?: number;
  reasoning?: number; totalTokens: number;
  cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
}

两个容易搞错的点:

  • reasoning 已经包含在 output 里了。 别再往总数上加一次。
  • cacheWrite1h 是 cacheWrite 的子集——用一小时保留期写入的那部分。

助手消息总是带 usage。工具结果可以带 usage——那是工具自己做的嵌套模型工作。

cost 的单位是美元。

stopReason 有 7 个取值

typescript
stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
取值含义
"pending"
消息还在流式生成中
"stop"
正常结束
"length"
撞上输出 token 上限
"toolUse"
模型要求调用工具
"error"
出错
"aborted"
被中止
"deferred"
供应商把这次回复延迟了

"pending" 只出现在流式过程中的半成品 assistant 消息上。 message_end 里那条完成的消息一定带终止性的 stop reason,而 Pi 不会把 "pending" 的 assistant 消息持久化进会话 JSONL。

"deferred" 类型的回复带一个 DeferredHandle,里面是取回这次回复需要的供应商数据(provider、modelId、api、id,可能还有 expiresAt、pollAfterMs、data)。

toolCall 的字段

typescript
interface ToolCall {
  type: "toolCall"; id: string; name: string;
  arguments: Record<string, any>;
  thoughtSignature?: string; namespace?: string;
}

thoughtSignature 是供应商相关的。namespace 标识 OpenAI Responses 的命名空间,用于动态加载或带命名空间的工具——MCP 工具走的就是这条路。

textSignature 和 thinkingSignature 是黑盒

TextContent.textSignature 和 ThinkingContent.thinkingSignature 都装的是供应商相关的元数据或重放数据。当成不透明字节处理,不要试图解析。

被 redacted 的思考块可以没有可见文本,但仍然保留 thinkingSignature 里的加密负载。


12.7 事实来源

本文所有数字、开关名、路径、默认值和枚举清单都可在仓库里复算(基线 commit 8efd314fa,tag v1.3.0-16-g8efd314fa):

断言来源
print / JSON / RPC 三种非交互模式的划分
packages/coding-agent/docs/cli-integration.md "Choose a mode" 表
--print / -p 开关
src/cli/args.ts:177
非 TTY 自动进 print 模式
src/main.ts resolveAppMode(parsed.print || !stdinIsTTY || !stdoutIsTTY)
print 模式的三种退出情形(0 / error·aborted / length)
src/modes/print-mode.ts:139-160
print 模式只打印最后一条 assistant 消息的文本块
同上 :140-152
print 模式异常时退出码 1、错误写 stderr
同上 :163-165
print 模式接受 @file、RPC 模式拒绝
src/modes/print-mode.ts:24 注释;docs/rpc.md "Start RPC mode"
ExtensionMode 四个取值
src/core/extensions/types.ts:323
严格 JSONL 帧、只按 LF 切、剥离可选 CR
src/modes/rpc/jsonl.ts:10-58
不用 Node readline 的原因(U+2028 / U+2029)
同上 :15-20 注释;docs/json.md "Framing and process I/O"
会话头形状(version 可选、timestamp 为 ISO 字符串)
src/core/session-manager.ts:43-50
RPC 模式不发会话头
docs/json.md "Session header";src/modes/rpc/rpc-mode.ts 无 header 输出
事件类型全集与字段
src/core/agent-session.ts:196-237 AgentSessionEvent;docs/json.md 各节
agent_end 之后仍可能有重试 / 压缩 / 队列工作
docs/json.md "Event sequence" 段
message_update 剥掉 partial、保留 usage
src/modes/json-event.ts:46-61
toolcall_start 在线上补 id 和 toolName
同上 :20-38
12 种嵌套事件类型
packages/ai/src/types.ts:806-828 AssistantMessageEvent
contentIndex 关联、*_end 带权威内容
docs/json.md "Reconstruct streaming messages"
tool_execution_* 三个事件的字段
docs/json.md "Tool execution events"
compaction_start / compaction_end 的 reason 三个取值
src/core/agent-session.ts:209,212-220
auto_retry_* 与 summarization_retry_* 字段
同上 :221-236;docs/json.md "Retry events"
JSON 模式失败响应不产生非 0 退出码
src/modes/print-mode.ts:139(退出码逻辑只在 mode === "text" 分支内);docs/cli-integration.md "Stream JSON events"
RPC 拒绝 @file 参数
docs/rpc.md "Start RPC mode"
RPC 四类记录
docs/rpc.md "Protocol records" 表
命令带 id、响应原样回显、异步所以要按 ID 关联
docs/rpc.md "Correlate commands and responses"
bash_execution_update 回显命令 ID
同上;src/core/agent-session.ts:3892
prompt 成功只代表已接受
docs/rpc.md "Run lifecycle"
disposition 三个取值
src/core/agent-session.ts:311-312 PromptDisposition;src/core/agent-session.ts:2090,2114,2138
success: true 在模型调用之前发出
src/core/agent-session.ts:1837-1863 _startRun 的 onStarted?.() 位置
success: false 的几种拒绝原因
src/core/agent-session.ts:2104-2106(streamingBehavior)、:2120-2136(模型 / 凭据)
handled 时不应等 agent_settled
docs/rpc.md "Run lifecycle";docs/rpc-commands.md prompt 节
解析失败返回无 ID 的 parse 响应
src/modes/rpc/rpc-mode.ts:750-764
关 stdin 即请求有序关闭
src/modes/rpc/rpc-mode.ts:802-805;docs/rpc.md "Shutdown"
SIGTERM 退出码 143、SIGHUP 退出码 129
src/modes/rpc/rpc-mode.ts:366-380
RpcClient 选项(cliPath 默认 dist/cli.js 等)
src/modes/rpc/rpc-client.ts:28-41,81
RpcClient 请求 ID 形如 req_N
同上 :576
单命令响应超时 30 秒
同上 :583
waitForIdle / collectEvents 默认超时 60 秒
同上 :471,491
start() 后等待 100 毫秒
同上 :133
stop() 先 SIGTERM、1 秒后 SIGKILL
同上 :150-163
RpcClient 从包入口导出
src/index.ts:424(export { RpcClient, ... } from "./modes/index.ts")
RPC 命令共 33 条
src/modes/rpc/rpc-types.ts:20-74 RpcCommand 联合(sed -n '20,75p' src/modes/rpc/rpc-types.ts | grep -o 'type: "[a-z_]*"' | sort -u | wc -l → 33)
各命令的参数与返回
src/modes/rpc/rpc-types.ts:20-242;docs/rpc-commands.md 逐条
prompt 运行中必须给 streamingBehavior
src/core/agent-session.ts:2104-2106
steering / follow-up 两种投递时机
docs/rpc-commands.md prompt 节;src/core/agent-session.ts:2110-2113
扩展命令运行中立即执行
docs/rpc-commands.md prompt 节;src/core/agent-session.ts:2065-2067
clear_queue + abort 实现 Esc 行为
docs/rpc-commands.md clear_queue 节
get_state 字段集
src/modes/rpc/rpc-types.ts:96-109 RpcSessionState
7 个思考等级
docs/rpc-commands.md set_thinking_level 节
get_available_thinking_levels 对无推理模型返回 ["off"]
同上 get_available_thinking_levels 节
队列模式两个取值与默认值
同上 set_steering_mode / set_follow_up_mode 节
estimatedTokensAfter 是启发式估算
docs/rpc-commands.md compact 节
bash 输出在下一次提示词才到达模型
docs/rpc-commands.md "How bash results reach the LLM";src/core/messages.ts:82-99,148-161
bashExecutionToText 追加退出码 / 取消 / 截断提示
src/core/messages.ts:90-97
get_entries 的 since 游标语义与 leafId
docs/rpc-commands.md get_entries 节;src/modes/rpc/rpc-mode.ts:636-647
get_commands 的三个 source 取值与字段
src/modes/rpc/rpc-types.ts:81-90;docs/rpc-commands.md get_commands 节
内置 TUI 命令不在 get_commands 里
docs/rpc-commands.md get_commands 节末尾的 Note
扩展 UI 9 个方法(4 对话框 + 5 通知)
src/modes/rpc/rpc-types.ts:252-287
extension_ui_response 的三种形状
同上 :294-297
扩展 UI 降级清单
src/modes/rpc/rpc-mode.ts:136-311;docs/rpc-extension-ui.md "Limitations"
ctx.mode === "rpc"、ctx.hasUI === true
docs/rpc-extension-ui.md "Limitations" 末段;src/core/extensions/runner.ts:628-630
notifyType 默认 "info"
docs/rpc-extension-ui.md notify 节
widgetPlacement 默认 "aboveEditor"
同上 setWidget 节
AgentMessage 联合的 8 个角色
docs/message-types.md "AgentMessage union"
消息用毫秒时间戳、条目用 ISO 8601
docs/message-types.md 开头段
Usage 字段与两个陷阱
同上 "Usage" 节
stopReason 七个取值
同上 "AssistantMessage" 节
"pending" 不持久化
同上
ToolCall 的 namespace 用途
同上 "ToolCall" 节
textSignature / thinkingSignature 不透明
同上 "TextContent" / "ThinkingContent" 节
声明合并可加自定义角色
同上 "AgentMessage union" 末段

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。