进程外集成:Print、JSON 与 RPC
从一次性问答到长驻进程的三种非交互式接法
Pi 有三种非交互式的接法,它们不是「同一个东西的三种输出格式」,而是三种不同的进程模型:
选择依据只有一条:你要在运行途中改变 Pi 的状态吗? 不需要就 print 或 JSON,需要就 RPC。
本文是 安装与运行模式 的下半篇。上一篇讲的是怎么把 Pi 装起来、四种模式怎么选;这篇讲的是选完之后,协议到底怎么跑,以及每一条会在什么地方坑你。
三种接法共用同一套 agent、会话、资源和工具。模式只决定三件事:输入怎么进来、输出怎么暴露、进程是否继续留着接受更多命令。
12.1 print 模式
一句话结论
print 模式是一次性问答:给一句提示词,拿一段最终文本,进程退出。它不暴露任何中间过程。
三种进入方式
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 模式只做三件事:
- 跑完你给的提示词;
- 把最终 assistant 消息里的文本块写到 stdout;
- 退出。
中间的事件、工具调用、思考过程一概不暴露。如果你的脚本需要知道「Pi 干了哪些工具」,print 模式给不了,换 JSON 模式。
一个实现细节决定了 stdout 的内容边界:print 模式取的是 state.messages 的最后一条消息,而且只在它是 assistant 消息时才打印文本。如果最后一条是别的角色(比如你连着给了多个提示词,最后一次还在跑),stdout 会是空的。
退出码是有语义的
这是 print 模式最值得写进脚本的部分:
第三行是重点:被截断的响应仍然把部分文本打到 stdout。这让「先拿个大概,够不够再调模型重试」的写法可行,但你必须同时看退出码,否则会把半截输出当成完整答案。
另外两种失败会直接走到 catch,错误信息写 stderr,退出码同样是 1:
try {
// ...
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}print 模式接受 @file
这一点和 RPC 模式正好相反,值得单独记一笔。
print 模式的初始消息允许包含 @file,由 Pi 展开成文件内容后再送进会话。RPC 模式拒绝 @file 参数,要用 prompt 命令送。
扩展看到的 mode
print 模式绑定扩展时传的 mode 是 "print":
await session.bindExtensions({
mode: mode === "json" ? "json" : "print",
// ...
});ExtensionMode 一共四个取值:"tui"、"rpc"、"json"、"print"。扩展如果按模式分支渲染,print 模式只有最基础的一套能力可用——它既没有终端 UI,也没有事件流订阅者。
什么时候用 print
12.2 JSON 事件流
一句话结论
JSON 模式是一次运行的结构化回放:所有提示词在进程启动时一次性给完,Pi 把整段过程写成 JSONL 事件流,然后退出。
它和 print 模式的区别不在「输出是不是机器可读」,而在中间过程可见。
pi --mode json "Review this repository" > events.jsonl帧协议:严格 JSONL
先说最容易被忽略、但一旦踩了就极难排查的一条:记录边界只有一个——LF(\n)。
U+2028 / U+2029 这两个字符是 Unicode 的行分隔符和段分隔符,在 JSON 字符串里是完全合法的。而 Node.js 的 readline 除了 \n 还会认这两个。
所以:
// 错的
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdout });
rl.on("line", (line) => { /* ... */ });// 对的:用 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 模式的第一条记录是当前会话的头:
{"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 命令。
事件全表
一次典型的运行会产生这样一串记录:
{"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_end 不是「跑完了」
这是 JSON 模式(和 RPC 模式)里第二个最容易写错的地方。
agent_end 关闭的是一次底层 agent 运行,不是整个会话级运行。 后面还可能跟着:
- 自动重试(
auto_retry_start/auto_retry_end) - 溢出恢复(
compaction_end里willRetry: true,然后 Pi 重试提示词) - 压缩重试
- steering 队列里排着的消息
- follow-up 队列里排着的消息
判断「这次真的跑完了」要看 agent_settled。 它的语义是「Pi 不会自动继续了」。
{"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:
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),
};
}转换做两件事:
- 删掉
assistantMessageEvent.partial。 那个字段是「到目前为止的完整 assistant 消息」,只有累积语义,没有增量语义。partial被剥掉后,流大小回到线性。 - 顶层
usage保留。 它是供应商上报的累计用量,体积恒定,剥了就没法算成本。
顺带一个副作用:toolcall_start 的 partial 里藏着工具调用的 id 和 name,剥掉 partial 就丢了这两个值。所以转换函数专门给 toolcall_start 补了回来:
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。 每个内容块有一个下标,增量事件都带着它:
一共 12 种,和 packages/ai/src/types.ts 里的 AssistantMessageEvent 联合类型一一对应。
定界的规矩是三条:
「替换」而不是「追加」是关键。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。
{"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 关联:
{"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 是追加(流式输出片段),别的工具可能给完整快照。
压缩事件
{"type":"compaction_start","reason":"threshold"}reason 有三个取值:"manual"、"threshold"、"overflow"。
成功时:
{
"type": "compaction_end", "reason": "threshold",
"result": {
"summary": "Summary of conversation...", "firstKeptEntryId": "abc123",
"tokensBefore": 150000, "estimatedTokensAfter": 32000,
"usage": {"...": "..."}, "details": {}
},
"aborted": false, "willRetry": false
}三种失败形态要分清:
溢出恢复成功时 willRetry 是 true,Pi 随后会重试那条提示词。客户端看到 compaction_end 且 willRetry: true,不要当成结束。
重试事件
assistant 回合的自动重试:
{"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 字符串。
压缩和分支摘要的重试是另一组事件:
{"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 就能用
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 收响应和事件。
pi --mode rpc --no-session其它命令行选项照常生效,用来选工作目录、模型、工具、资源和会话行为:--provider、--model、--name、--no-session、--session-dir。
RPC 模式拒绝 @file 提示词参数,提示词要用 prompt 命令送。
四类记录
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 还有几条要求:
用 id 关联,不要靠顺序
每条命令都可以带一个可选的字符串 id,匹配到的响应会原样回显这个 id:
{"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 部分最容易写错、后果也最严重的一条。
{"id":"req-2","type":"prompt","message":"Review this repository"}
{"id":"req-2","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}一次成功的 prompt 响应,意味着提示词被接受、排队或处理了。它不代表模型的工作完成了。
data.disposition 有三个取值:
"handled" 这一档特别重要:如果提示词被扩展吃掉了,压根不会有运行,也就不会有 agent_settled 事件。等它会一直等到超时。
为什么响应会这么早?看 src/modes/rpc/rpc-mode.ts 里 prompt 的实现:
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 里的位置是:
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(),它内部先装监听器再发提示词:
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 的响应:
{"id":"req-3","type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}JSON 解析失败返回一条没有请求 ID 的响应,command 是 "parse":
{"type":"response","command":"parse","success":false,"error":"Failed to parse command: Unexpected token..."}还有几类错误不在协议里,客户端必须自己处理:
- 子进程启动失败
- 意外退出
- stderr 上的诊断信息
- 取消
- 自己的超时
成功响应只覆盖命令处理本身。 提示词被接受之后的供应商失败和中止,出现在消息和事件流里。
关闭:关掉 stdin
关闭子进程的 stdin 就是请求有序关闭。 Pi 会先 dispose 掉当前的 runtime 再退出。
process.stdin.close()
process.wait()客户端仍然要自己处理信号和意外退出。
Pi 自己也注册了信号处理器:
另外,扩展可以通过扩展上下文请求关闭。Pi 会在当前命令处理完、或者当前运行发出 agent_settled 之后完成关闭。
扩展也能请求关闭:bindExtensions 的 shutdownHandler 回调里置一个标志,Pi 在每次 agent_settled 事件后检查它。
RpcClient:不要手搓协议
如果你的客户端是 TypeScript 或 Node.js,用导出的 RpcClient,不要自己实现 JSONL 帧。
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 的构造选项:
它内部做的事:启动 node <cliPath> --mode rpc ... 子进程、用严格 JSONL 读 stdout、按 ID 关联请求和响应、把事件投递给监听器。
几个值得知道的实现细节:
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 分隔符当边界):
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
已经在 Node 里、想要直接的类型访问,就用 SDK 嵌入(下一篇讲)。需要跨进程、语言无关、或者要隔离,就用 RPC。
12.4 RPC 命令表
一共 33 条命令。全部接受可选的字符串 id,匹配的响应会回显它。
全表
下面挑几条语义不显然的展开讲。
prompt:唯一需要 streamingBehavior 的命令
运行中再发 prompt 而不给 streamingBehavior,命令会返回错误:
{"type":"prompt","message":"New instruction","streamingBehavior":"steer"}两个例外:
- 扩展命令(比如
/mycommand)即使在运行中也会立即执行,不需要streamingBehavior。扩展命令自己管理 LLM 交互(通过pi.sendMessage())。 - skill 命令(
/skill:name)和提示词模板(/template)在发送/排队前会被展开。
prompt 的响应:
{"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 队列,并返回它们的文本:
{
"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 事件,响应里是最终结果:
{
"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 消息:
Ran `ls -la`
```
total 48
drwxr-xr-x ...
```所以:
- bash 输出在「下一次提示词」才到达模型,不是立刻。
- 一次提示词之前可以跑多条 bash 命令,Pi 会把每一条没设
excludeFromContext的输出都带上。
转换函数在 src/core/messages.ts 的 bashExecutionToText(),它还会在末尾追加退出码、取消标记和截断提示。
get_entries:可以当持久游标用
{"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
{
"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:可发现命令
{
"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"
}
}
]
}
}每条命令的字段:
skill 的 name 带 skill: 前缀。
内置的 TUI 命令(/settings、/hotkeys 这些)不在列表里。 它们只在交互式模式下处理,通过 prompt 发过去也执行不了。
其余命令的细节
get_state 的返回是一个扁平的状态对象:
{
"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 模式下,这些调用变成一套与常规命令并列的请求-响应子协议。
两类方法
一共 9 个方法,4 个对话框 + 5 个通知。
如果对话框方法带了 timeout 字段,agent 侧会在超时后自动用默认值 resolve。客户端不需要自己计时。
请求长什么样
所有请求都有 type: "extension_ui_request"、一个唯一的 id 和一个 method 字段。
select(完整形状):
{
"type": "extension_ui_request",
"id": "uuid-1",
"method": "select",
"title": "Allow dangerous command?",
"options": ["Allow", "Block"],
"timeout": 10000
}confirm(完整形状):
{
"type": "extension_ui_request",
"id": "uuid-2",
"method": "confirm",
"title": "Clear session?",
"message": "All messages will be lost.",
"timeout": 5000
}其余七个方法的字段:
setWidget 在 RPC 模式下只支持字符串数组,组件工厂会被忽略。
响应怎么回
只有对话框方法需要响应。id 必须和请求对上。
取消时扩展收到的值是:select / input / editor 收到 undefined,confirm 收到 false。
扩展 UI 响应不产生常规命令响应。 它在 RPC 模式里被单独识别和处理。
RPC 模式下的能力降级
有些 ExtensionUIContext 方法需要直接访问终端,在 RPC 模式下不可用或降级:
ctx.mode 在 RPC 模式下是 "rpc",ctx.hasUI 是 true。 因为对话框和即发即忘方法通过扩展 UI 子协议是能用的。
这一条很容易写反:扩展作者看到 hasUI === true 就以为终端能力都在,于是调用 custom() 画组件——结果拿到 undefined。要判断 TUI 专属能力,用 ctx.mode === "tui"。
12.6 消息类型与时间戳陷阱
AgentMessage 是 8 个角色的联合
Pi 的 AgentMessage 联合类型有 8 个角色:
bashExecution 值得单独强调:它是 RPC bash 命令和交互式 ! 命令产生的那种消息,不是模型调用工具的结果。 它会被转成 user 角色的文本再发给模型(convertToLlm 里的 bashExecution 分支)。
custom 和 bashExecution 都有 excludeFromContext 之类的语义差别:custom 的 display 控制终端渲染,details 不发给模型。
在底层的 agent 包里,AgentMessage 的定义更宽,是 Message | CustomAgentMessages[keyof CustomAgentMessages]。应用可以通过 TypeScript 声明合并加自己的角色,所以消费别人宿主传过来的消息时,要容忍未知的自定义角色。
双时间戳
这是最容易踩的一个坑:
会话头是 ISO 字符串,消息是毫秒数。 同一次运行里这两者会同时出现——会话头只有一条,消息有几十条。
写客户端代码时,排序用 entry.timestamp(ISO,可以直接字典序比较),做时间差计算用 message.timestamp(毫秒,可以直接相减)。混用会得到完全错误的结果,而且不会报错。
Usage 的形状
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 个取值
stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";"pending" 只出现在流式过程中的半成品 assistant 消息上。 message_end 里那条完成的消息一定带终止性的 stop reason,而 Pi 不会把 "pending" 的 assistant 消息持久化进会话 JSONL。
"deferred" 类型的回复带一个 DeferredHandle,里面是取回这次回复需要的供应商数据(provider、modelId、api、id,可能还有 expiresAt、pollAfterMs、data)。
toolCall 的字段
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):
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。