Skip to content

6.10 交互模式与 RPC 模式 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:pi、pi -p、pi --mode json、pi --mode rpc 看起来像四个不同的程序,它们到底共享多少东西、又在哪一行代码上分岔?RPC 模式在 stdin/stdout 上说的那套协议长什么样,为什么第三方能靠它给 Pi 套一个 Web 界面? 前置知识:4.4 从哪里开始读源码(resolveAppMode 在那里第一次出现)、5.1 启动:pi 命令如何跑起来、5.4 流式事件如何传播到界面。 学习目标:读完后你能 ① 指出四种模式共享的那一行代码,并说清「壳层」各自负责什么;② 说明 --mode json 为什么不是第四种运行时;③ 复述 RPC 协议的帧格式规则与三类出站消息,并解释 id 的关联规则;④ 讲清扩展 UI 请求这条反向通道怎么走;⑤ 亲手把一行 JSON 喂进 pi --mode rpc 并看懂它的回答。

建立直觉:一个引擎,四个壳 ​

第四部分说过「Pi 有四种使用方式,共享同一个核心」。这句话很容易被当成宣传口号读过去。本章的核心论点是:它在源码里是一个可以精确指出行号的事实——四种模式在 main() 里走到同一个 AgentSessionRuntime 对象,然后才分道扬镳;分岔之后的代码只负责「话怎么说出口、命令怎么收进来」,不碰 Agent Loop(Agent 循环)、工具、会话存储中的任何一行。

📘 概念壳层(Shell / front-end)
包裹在同一套业务核心外面、只负责输入输出形式的那一层。换壳不换核:终端界面(TUI,Terminal User Interface)壳把事件画成像素,JSON 壳把同一批事件打成文本行,RPC 壳还额外把外部命令翻译成方法调用。判断一段代码属不属于壳层的办法很简单:把它删掉,Agent 还能不能正常跑完一轮对话。

真实采集的帮助文本里,这件事只体现为一行选项(research/cli-captures/pi-help.txt:22,真实采集):

  --mode <mode>                  Output mode: text (default), json, or rpc

注意它的措辞是 Output mode(输出模式)——官方帮助自己就把 --mode 定位成「输出形式」而不是「运行时种类」。下面我们用源码验证这句话。

四种模式的真实分岔点 ​

第一步:决定 appMode ​

earendil-works/pi@16787ad第 111–126 行在 GitHub 查看 ↗
模式决策与输出模式映射:显式 --mode 优先;-p 或任一标准输入输出不是终端(TTY)时进入 print;否则交互模式。toPrintOutputMode 把 json 折回 print 模式的输出变体。
ts
// packages/coding-agent/src/main.ts:111-126
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
	if (parsed.mode === "rpc") return "rpc";
	if (parsed.mode === "json") return "json";
	if (parsed.print || !stdinIsTTY || !stdoutIsTTY) return "print";
	return "interactive";
}

function toPrintOutputMode(appMode: AppMode): Exclude<Mode, "rpc"> {
	return appMode === "json" ? "json" : "text";
}

AppMode 有四个取值,但 --mode 只接受三个字符串(text | json | rpc,解析见 packages/coding-agent/src/cli/args.ts:95-99)。interactive 不是你能显式要求的模式,而是「什么特殊条件都不满足」时的默认值——这解释了为什么 pi --mode text 在终端里跑仍然会进交互模式(resolveAppMode 根本没有 text 分支)。

第二步:造出唯一的 runtime ​

packages/coding-agent/src/main.ts · createAgentSessionRuntime
earendil-works/pi@16787ad第 845–849 行在 GitHub 查看 ↗
四种模式的公共汇合点:无论 appMode 是什么,main() 都在这里造出同一个 AgentSessionRuntime。
ts
// packages/coding-agent/src/main.ts:845-849
const runtime = await createAgentSessionRuntime(createRuntime, {
	cwd: sessionManager.getCwd(),
	agentDir,
	sessionManager,
});

这一行之前的所有工作——参数解析、设置合并、项目信任、会话管理器选择、模型解析、扩展(Extension)加载——四种模式走的是同一条路。从源码结构看,main.ts:607 到 main.ts:849 之间按 appMode 分岔的地方全是外围判断——决定「能不能弹交互提示」「stdout 归谁」——都不改变构造出来的对象。几处典型:非交互模式提前 takeOverStdout()(main.ts:638-642;纯粹的 --help / --list-models 例外,判断在 main.ts:128-130)、RPC 模式拒绝 @file 参数(main.ts:644-647)、只有交互模式才可能跑首次启动向导(main.ts:661-664)、会话记录的工作目录不存在时只有交互模式会弹窗让你重新选(main.ts:683),项目信任提示也按模式决定能不能弹界面(main.ts:710)。

earendil-works/pi@16787ad第 67–79 行在 GitHub 查看 ↗
AgentSessionRuntime 持有当前 AgentSession 与一组绑定到 cwd 的服务,并提供整体重建能力;类注释写明「会话替换先拆后建」。

第三步:分发到壳层 ​

earendil-works/pi@16787ad第 930–980 行在 GitHub 查看 ↗
三分支收尾:rpc 交给 runRpcMode,interactive 交给 InteractiveMode,其余全部交给 runPrintMode,并用 toPrintOutputMode 决定文本还是 JSON 输出。
ts
// packages/coding-agent/src/main.ts:930-980(省略:printTimings 计时输出、PI_STARTUP_BENCHMARK 分支与退出码处理)
if (appMode === "rpc") {
	await runRpcMode(runtime);
} else if (appMode === "interactive") {
	const interactiveMode = new InteractiveMode(runtime, {
		// …(省略:初始消息、诊断信息、主题等选项)
		tuiMode: parsed.tuiMode,
	});
	await interactiveMode.run();
} else {
	const exitCode = await runPrintMode(runtime, {
		mode: toPrintOutputMode(appMode),
		messages: parsed.messages,
		initialMessage,
		initialImages,
	});
}

三个函数的第一个参数都是同一个 runtime 对象。这就是本章开头那句论点的全部证据:差异只在壳层。交互模式多收的 tuiMode 也只是壳层内部的选择——用常规渲染器还是全屏渲染器画界面(见 6.9 pi-tui),与 runtime 无关。

图 6.10-1 四模式共享核心,只在壳层分叉
阅读顺序:自上而下。请特别关注中间那个收窄的瓶颈节点——四条出边全部来自同一个 runtime 对象,而不是四次独立构造。图中每个节点都标了真实源码位置:resolveAppMode 在 main.ts:111,runtime 在 main.ts:845 创建,分发在 main.ts:930,json 与 print 共用 runPrintMode,只是 mode 参数不同。

壳层各自做什么 ​

从源码结构看,每个壳层都要回答同样的三个问题,只是答案不同:

问题interactiveprint / jsonrpc
输入从哪来键盘与 TUI 组件命令行参数 + 管道 stdinstdin 上的 JSONL 命令
事件往哪去渲染成终端画面json 逐行打印,text 只打最后一条逐行打印 + 命令响应
扩展要弹窗怎么办真的弹一个 TUI 对话框无 UI 上下文发一条 extension_ui_request 给宿主

每个壳层都要实现一个 rebindSession,在会话被替换(/new、/fork、切换会话)之后把新 AgentSession 重新绑上自己的输入输出。RPC 版把 mode: "rpc" 和 RPC 风格的 uiContext 绑上去(packages/coding-agent/src/modes/rpc/rpc-mode.ts:317-364),print 版绑 mode: "json" | "print" 且没有 uiContext(packages/coding-agent/src/modes/print-mode.ts:74-119)。这个「模式名」会传给扩展,所以扩展能知道自己跑在哪种壳里。

RPC 协议精讲 ​

帧格式:严格 JSONL,只认换行 ​

earendil-works/pi@16787ad第 4–12 行在 GitHub 查看 ↗
一条记录 = JSON.stringify 结果 + 一个 LF。注释明确要求客户端只按 \n 切分,因为负载字符串里可能合法地出现 U+2028 / U+2029。

RPC 协议的帧格式(framing)简单到只有一句话:一行一条 JSON,行分隔符只有 \n。但「只有 \n」这四个字是有代价的——读取端不能图省事用 Node 自带的 readline:

ts
// packages/coding-agent/src/modes/rpc/jsonl.ts:14-20(注释原文节选)
/**
 * Attach an LF-only JSONL reader to a stream.
 *
 * This intentionally does not use Node readline. Readline splits on additional
 * Unicode separators that are valid inside JSON strings ...
 */

readline 会把 U+2028(行分隔符)和 U+2029(段分隔符)也当作换行。这两个字符在 JSON 字符串里是合法字符,模型输出的文本里完全可能出现。一旦被误当成帧边界,一条消息就会被劈成两截,两截都不是合法 JSON——症状是「偶发地、只在某些回复内容下解析失败」,是那种能查一整天的 bug。Pi 的做法是自己写 58 行的 attachJsonlLineReader(jsonl.ts:21-58):只找 \n,顺手容忍行尾的 \r(兼容 CRLF),流结束时把缓冲区里没有换行结尾的最后一行也吐出来。

这些行为都有对应的测试用例,集中在 packages/coding-agent/test/rpc-jsonl.test.ts:5(describe("RPC JSONL framing"),四个用例分别覆盖:序列化不转义 Unicode 分隔符、只按 LF 切分并保留 U+2028/U+2029、容忍 CRLF、无尾部换行的最后一行也会吐出;本书实测这 4 个用例全部通过)。

⚠️ 常见误解用 JSON.parse 直接吃整块 stdout
子进程的一次 data 事件既可能只给你半行,也可能一次给你三行,甚至把一个多字节的 UTF-8 汉字劈在两次 data 之间。必须自己维护缓冲区按 \n 切。jsonl.ts:29-41 的 onData 就是最小样例:先用 StringDecoder 把字节解码成字符串(它会把半个汉字留到下一次再拼),追加进 buffer,循环找 \n,切一行处理一行。另外提醒一句:仓库自带的示例 packages/coding-agent/examples/rpc-extension-ui.ts:521 读子进程 stdout 用的恰恰是 readline.createInterface,照抄它做正式集成时,记得换成 LF-only 的读法。

stdin:33 种命令 ​

earendil-works/pi@16787ad第 20–74 行在 GitHub 查看 ↗
入站命令的可辨识联合(Discriminated Union),共 33 个分支,按提问、状态、模型、思考等级、队列、压缩、重试、bash、会话、消息、命令分组,每个分支都带可选的 id。

33 种命令不必背,挑六个有代表性的看结构就够:

命令形状说明
prompt{ id?, type, message, images?, streamingBehavior? }发起一轮对话,响应语义特殊(见下)
get_state{ id?, type }取一份状态快照 RpcSessionState(rpc-types.ts:96-109)
abort{ id?, type }打断当前流式输出
clear_queue{ id?, type }清空还没送出的排队消息,响应的 data 里带回被清掉的 { steering, followUp }(rpc-mode.ts:433-435、rpc-types.ts:122-128)
fork{ id?, type, entryId }从某个会话条目分叉,落到 runtime.fork
bash{ id?, type, command, excludeFromContext? }执行 shell 命令,输出以事件流回来

所有命令的 id 都是可选的。给了 id,对应响应会带同一个 id;不给,你就只能靠 command 字段猜。官方文档说明(packages/coding-agent/docs/rpc.md:26)额外提到一个特例:bash_execution_update 事件也会携带发起它的那条 bash 命令的 id。这一条在源码里可以复核——AgentSessionEvent 的所有分支里,只有 bash_execution_update 带 id 字段(agent-session.ts:205),它是唯一一个把 id 带进事件的地方。

stdout:三类消息 ​

earendil-works/pi@16787ad第 50–62 行在 GitHub 查看 ↗
RPC 壳层的入口:先 takeOverStdout 把杂散输出赶到 stderr,然后所有协议消息都经由 output 这一个函数写到原始 stdout。
ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:54-62
export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<never> {
	takeOverStdout();
	let session = runtimeHost.session;
	// …(省略:两个取消订阅句柄的声明)
	const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
		writeRawStdout(serializeJsonLine(obj));
	};

output 的参数类型把出站消息的主要种类写在了签名里(最后那个 object 兜住其余形状):

  1. 命令响应 RpcResponse(rpc-types.ts:116-239):统一形状 { id?, type: "response", command, success, data? };失败分支是联合体的最后一支 { id?, type: "response", command: string, success: false, error: string }(rpc-types.ts:239)。
  2. 会话事件:订阅回调里写的是 output(toJsonEvent(event))(rpc-mode.ts:355-360)。toJsonEvent 对绝大多数事件原样放行,只改写一种——message_update(见下一小节)。改写后的事件类型叫 JsonAgentSessionEvent(packages/coding-agent/src/modes/json-event.ts:18),底子仍是 AgentSessionEvent(packages/coding-agent/src/core/agent-session.ts:164-205)。这是 RPC 与 json 模式共用的同一套事件负载。
  3. 扩展 UI 请求 RpcExtensionUIRequest(rpc-types.ts:246-281):九种 method(select / confirm / input / editor / notify / setStatus / setWidget / setTitle / set_editor_text),是唯一一类由 Pi 主动发起、需要宿主回话的消息。

签名之外还有一种零星消息:扩展抛错时,RPC 壳层会发一条 { type: "extension_error", extensionPath, event, error }(rpc-mode.ts:349),宿主把它当成一种特殊事件处理即可。

takeOverStdout() 的作用值得单独说一句:它把 process.stdout.write 整个替换成写 stderr(packages/coding-agent/src/core/output-guard.ts:45-70),协议输出则走事先保存的原始 write(writeRawStdout,output-guard.ts:85-93)。结果是:任何依赖、任何扩展、任何忘了删的 console.log 都污染不了协议流。仓库里有专门的回归测试守着这条线:packages/coding-agent/test/stdout-cleanliness.test.ts:98,它真的 spawn 一个 CLI 进程去验证 --mode json --help 时 stdout 一个字节的杂音都没有。

流式更新只发增量 ​

模型边生成边往外吐字时,Agent 内部每来一小段就产生一个 message_update 事件,事件里带着到目前为止的整条消息(message)和这一小段的描述(assistantMessageEvent,其中又有一份截至此刻的完整快照 partial)。如果原样写到 stdout,前面已经发过的内容会在之后的每个事件里再重复一遍,回复越长,重复越多。官方文档把这样改写的目的说成让流的大小保持线性(packages/coding-agent/docs/json.md:87-88)。

earendil-works/pi@16787ad第 40–61 行在 GitHub 查看 ↗
线上事件的唯一改写点:非 message_update 事件原样返回;message_update 去掉累积的 message 与 partial,只留增量、当前用量 usage;toolcall_start 额外补上工具调用的 id 与 toolName。
ts
// packages/coding-agent/src/modes/json-event.ts:48-61
export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent {
	if (event.type !== "message_update") {
		return event;
	}
	// …(省略:message 必须是 assistant 的检查)
	return {
		type: "message_update",
		usage: event.message.usage,
		assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent),
	};
}

读法:线上的 message_update 只剩三样东西——type、当前累计的 usage(用量,大小固定)、去掉了 partial 的增量事件(json-event.ts:20-38)。宿主想要「实时的半条消息」,得自己拿 contentIndex 和 delta 拼;message_start 给出初始消息,message_end 给出最终权威版本(函数上方注释 json-event.ts:40-45)。官方 RPC 文档对这一点有专门说明(packages/coding-agent/docs/rpc.md:938-995)。

prompt 的「一次且仅一次」响应 ​

其它命令都是「做完返回一个响应」,prompt 不是:

ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:394-416
case "prompt": {
	let preflightSucceeded = false;
	void session
		.prompt(command.message, {
			// …(省略:images / streamingBehavior / source 三个透传字段)
			preflightResult: (didSucceed) => {
				if (didSucceed) {
					preflightSucceeded = true;
					output(success(id, "prompt"));
				}
			},
		})
		.catch((e) => {
			if (!preflightSucceeded) {
				output(error(id, "prompt", e.message));
			}
		});
	return undefined;
}

读法:prompt 立刻返回 undefined(不走通用的「返回值即响应」路径),改由 preflightResult 回调在预检通过那一刻发 success。预检之前失败发一次 error;预检之后再失败(比如模型报错)就不再发响应了,只走事件流。所以宿主程序必须记住:一条 prompt 命令恰好对应一个响应,之后的一切都在事件里。这条语义有专门的单元测试:packages/coding-agent/test/rpc-prompt-response-semantics.test.ts:185(describe("RPC prompt response semantics"),覆盖预检失败、预检成功、流式中排队三种情形)。

双向:扩展 UI 请求 ​

earendil-works/pi@16787ad第 752–802 行在 GitHub 查看 ↗
入站单行处理:JSON 解析失败发 command 为 parse 的失败响应;extension_ui_response 被截住去 resolve 挂起的 Promise;其余交给 handleCommand,异常统一转成失败响应。

交互模式下扩展调 uiContext.confirm(...) 会弹一个 TUI 对话框。RPC 模式没有终端可弹,于是 createExtensionUIContext(rpc-mode.ts:136-311)把这些调用改写成「发一条 extension_ui_request 到 stdout,然后 await 一个 Promise」;这个 Promise 被登记在 pendingExtensionRequests 里(rpc-mode.ts:91-131 的 createDialogPromise),等宿主从 stdin 回一条同 id 的 extension_ui_response(rpc-types.ts:288-291)才被 resolve。handleInputLine 在把行交给 handleCommand 之前先拦这一类消息(rpc-mode.ts:768-782)——这是 stdin 上唯一不是 RpcCommand 的东西。

图 6.10-2 RPC 模式一轮对话的完整消息流
阅读顺序:自上而下按编号。请关注三处:第 4 步的响应只出现一次(rpc-mode.ts:403-407);第 5–6 步的事件流不带 id,与命令响应属于两条并行的信息通道;第 7–10 步是反向请求,箭头方向和第 1 步相反,宿主此刻扮演的是「UI 服务器」的角色(rpc-mode.ts:129 发出、rpc-mode.ts:768-782 收回)。

图中还有一个不出现在协议里、但影响吞吐的细节:事件转发时挂了背压(backpressure)控制——session.agent.subscribe 的回调里 await waitForRawStdoutBackpressure()(rpc-mode.ts:361-363),也就是说宿主读得慢,Agent 的事件生产就会被拖慢,而不是在内存里无限堆积。

完整调用链 ​

一条 prompt 命令从字节到模型请求,链路如下(每一环都可以自己 grep 复核):

cli.ts:6 main() → main.ts:607 parseArgs → main.ts:638 resolveAppMode → main.ts:845 createAgentSessionRuntime → main.ts:932 runRpcMode(runtime) → rpc-mode.ts:810 attachJsonlLineReader(process.stdin, …) → rpc-mode.ts:752 handleInputLine → rpc-mode.ts:386 handleCommand → rpc-mode.ts:399 session.prompt(...) → 之后进入 5.2 讲过的主链路。

反向的事件链路:session.subscribe 回调(rpc-mode.ts:355-360)→ toJsonEvent(json-event.ts:48)→ output(...)(rpc-mode.ts:60-62)→ serializeJsonLine(jsonl.ts:10)→ writeRawStdout(output-guard.ts:85-93)→ 真正的 stdout。

json 模式:把同一批事件单向倒出来 ​

earendil-works/pi@16787ad第 108–127 行在 GitHub 查看 ↗
json 模式的全部输出逻辑:订阅会话事件、经 toJsonEvent 改写后逐行打印;开跑前先打印一行会话头。没有任何 stdin 命令处理。
ts
// packages/coding-agent/src/modes/print-mode.ts:108-127(省略:背压订阅)
unsubscribe = session.subscribe((event) => {
	if (mode === "json") {
		writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`);
	}
});
// …(省略:rebindSession 的其余部分)
if (mode === "json") {
	const header = session.sessionManager.getHeader();
	if (header) {
		writeRawStdout(`${JSON.stringify(header)}\n`);
	}
}

于是 pi --mode json "..." 的输出形态是:首行会话头,其后每行一个事件(事件经过和 RPC 模式同一个 toJsonEvent,所以 message_update 同样只有增量),发完 initialMessage 与 messages 就结束(print-mode.ts:131-137),最后 disposeRuntime 并 flush(print-mode.ts:162-168)。

和 RPC 模式对照着看,区别一目了然:

--mode json--mode rpc
stdin当作管道内容读进来拼进 prompt当作命令通道,不读管道(main.ts:873-880)
出站消息种类只有事件(外加首行会话头)事件 + 命令响应 + 扩展 UI 请求
生命周期跑完预置消息就退出常驻,靠 stdin EOF 或信号退出(rpc-mode.ts:804-807、366-380)
能否改模型/分叉/压缩不能能,33 种命令

一句话:json 是单向事件流,rpc 是双向会话协议。前者适合「跑一次、把过程录下来」,后者适合「长期驱动一个 Agent」。runPrintMode 有独立单测:packages/coding-agent/test/print-mode.test.ts:93(describe("runPrintMode"))。

🌱 初学者提示文档漂移:以源码为准
官方文档 packages/coding-agent/docs/json.md 对事件形态的描述与实现一致:json.md:11-29 写的正是 JsonAgentSessionEvent,json.md:87-92 也讲清了 message_update 只发增量。过期的是「消息类型」一节的行号:json.md:56-59 说 UserMessage / AssistantMessage / ToolResultMessage 在 packages/ai/src/types.ts 的第 134 / 140 / 152 行,本书锁定版本里它们在第 509 / 515 / 539 行,而且那份清单没有列出同一联合里的 SystemMessage(types.ts:553)。文档里的行号会随代码漂移,遇到分歧时,以你亲自 Read 到的源码为准。

RPC 的独立入口,以及一个同名不同路的 server 包 ​

rpc-entry:只做一件事的入口文件 ​

除了 pi --mode rpc,主包还单独导出了一个 RPC 入口(packages/coding-agent/package.json:19-21 的 "./rpc-entry",指向打包产物 dist/bundle/rpc-entry.js)。源码是 packages/coding-agent/src/rpc-entry.ts:1-13(全文 13 行),它做的事就是把 --mode rpc 硬编码进参数再调 main:

ts
// packages/coding-agent/src/rpc-entry.ts:6-13
process.title = `${APP_NAME}-rpc`;
process.env.PI_CODING_AGENT = "true";
process.env.AI_AGENT = "pi";
process.emitWarning = (() => {}) as typeof process.emitWarning;

configureHttpDispatcher();

main(["--mode", "rpc", ...process.argv.slice(2)]);

也就是说,它和 pi --mode rpc 走的是同一个 main()、同一条分发路径,只是多设了进程标题和两个环境变量,并吞掉 Node 的警告输出。想从自己的 Node 程序里拉起一个 RPC 子进程、又不想依赖全局装好的 pi 命令时,可以直接 spawn 这个文件。

server 包:不是 RPC 模式的消费者 ​

仓库里还有一个名字很容易让人以为和本章有关的包:packages/server。在本书锁定版本里,它和 RPC 模式没有关系,这里单独说清楚,免得你顺着名字找错方向。

先说定位。包描述是 "experimental server package for pi"(packages/server/package.json:4),README 首段说它是一个服务于「新的持久化 Session 与 Agent Harness 接口」的实验性本地服务器(packages/server/README.md:3)。它有两个配套包:packages/protocol(包描述 "Transport-neutral CBOR protocol for remote pi sessions",packages/protocol/package.json:4)和 packages/client(packages/client/package.json:4)。根 README 的包列表里这三个都没有出现(README.md:28-36)。

它和 RPC 模式的差别,一张表就能看清:

RPC 模式(本章)pi-protocol(server / client 包)
帧格式一行一条 JSON,只认 \n4 字节无符号大端长度前缀 + 一个 CBOR 数据项(packages/protocol/src/framing.ts:27-39;单帧默认上限 16 MiB,framing.ts:6)
版本协商没有先握手,协议版本常量 PROTOCOL_VERSION = 8(packages/protocol/src/protocol.ts:5)
驱动的对象一个子进程里的 AgentSessionRuntime持久化的 Session 与 Agent Harness,请求要带 { serverId, sessionId, attachmentId } 路由(packages/protocol/README.md:13)
谁在用RpcClient、第三方宿主主包 src/experimental/ 下的实验代码

最后一行值得展开。主包里用到这三个包的代码都在 packages/coding-agent/src/experimental/ 与 src/cli/experimental/ 下,入口是 experimental/cli.ts:只有环境变量 PI_EXPERIMENTAL=1(packages/coding-agent/src/core/experimental.ts:1-3)且第一个参数是 server 或 client 时,才会走进这批实验命令,否则照常调 main()(packages/coding-agent/src/experimental/commands.ts:92-106,函数上方注释写明这是仅供开发用的分发,发布出去的入口不得引用它)。主包的 files 字段也把 dist/experimental 与 dist/cli/experimental 排除在发布内容之外(packages/coding-agent/package.json:29-33)。你用 npm 装到的 pi 里没有这些命令。

🌱 初学者提示pi-test.sh 跑的是实验入口
本章练习用的 pi-test.sh 最后执行的是 packages/coding-agent/src/experimental/cli.ts(pi-test.sh:57)。只要你没设 PI_EXPERIMENTAL=1,它就原样转交给 main(),行为与正式的 pi 一致,所以不影响下面的练习。

据此推断(尚未在源码中直接证实):server / protocol / client 这条线是 Pi 为「远程、可持久的会话」准备的新方向,和 RPC 模式并行演进,而不是 RPC 的下一版。尚未确认:它何时(以及是否)会进入发布内容;在那之前,想从外部驱动 Pi,稳定可用的仍然是本章讲的 RPC 模式。

第三方集成为什么成为可能 ​

4.1 提过第三方社区 Web 界面 agegr/pi-web。现在可以给出协议层面的解释了:任何语言、任何进程只要能做到三件事,就能驱动一个完整的 Pi——

  1. spawn 一个 pi --mode rpc 子进程(或直接 spawn 上一节的 rpc-entry);
  2. 按 LF-only 规则切行、解析 JSON;
  3. 把 id 对得上的 response 配对,其余当事件广播,extension_ui_request 回一条 extension_ui_response。

仓库里现成的参考实现是官方 TypeScript 客户端 RpcClient:packages/coding-agent/src/modes/rpc/rpc-client.ts:74-98 负责 spawn,rpc-client.ts:127-130 用本章讲过的 attachJsonlLineReader 切行,516-535 的 handleLine 就是上面第 3 条的 20 行版本(id 对得上就交给等待中的请求,其余当事件广播)。RpcClient、runRpcMode、全部 RPC 类型以及事件类型 JsonAgentSessionEvent 都从主包入口导出(packages/coding-agent/src/index.ts:386-403),所以 Node 宿主不必去挖内部路径。另一个可以对照着读的是带界面的示例 packages/coding-agent/examples/rpc-extension-ui.ts,它演示了怎么应答 extension_ui_request(切行方式见前面「常见错误」里的提醒)。

需要如实标注的边界:本书没有分析 pi-web 的源码,它与本书锁定版本的兼容性尚未确认(见 继续阅读与参考资料)。另一个尚未确认的点是跨版本兼容策略:RPC 协议本身没有 hello 或 version 消息,RpcClient.start() 甚至只是固定 sleep 100 毫秒等子进程起来(rpc-client.ts:132-133),没有 ready 握手——这一点和上一节带版本握手的 pi-protocol 正好相反。一种看法是「协议越简单越好,版本协商交给调用方锁版本」,代价是宿主升级 Pi 后只能靠跑一遍集成测试来发现不兼容。

🛠 实践任务喂一行 JSON 给 pi --mode rpc

目标:在完全没有 API Key 的前提下,亲手驱动一次 RPC 模式,观察成功响应、解析失败响应、未知命令响应三种形态。

步骤与命令(把 ~/pi 换成你本地 Pi 仓库路径;所有命令都不写入 Pi 仓库):

text
# 1. 准备两个空目录:一个当隔离的 Pi 配置目录,一个当工作目录
mkdir -p /tmp/pi-rpc-demo/agent /tmp/pi-rpc-demo/work
cd /tmp/pi-rpc-demo/work

# 2. 送一条 get_state 命令进去
printf '%s\n' '{"id":"1","type":"get_state"}' \
  | env PI_CODING_AGENT_DIR=/tmp/pi-rpc-demo/agent \
    bash ~/pi/pi-test.sh --no-env --mode rpc --no-session

# 3. 一次送三行:坏 JSON、未知命令、正常命令
printf '%s\n' 'not json' '{"id":"2","type":"no_such_command"}' '{"id":"3","type":"get_messages"}' \
  | env PI_CODING_AGENT_DIR=/tmp/pi-rpc-demo/agent \
    bash ~/pi/pi-test.sh --no-env --mode rpc --no-session

预期现象(本书在锁定版本上实测,真实输出;第 2 步的 data.model 对象较长,此处用 … 折叠,其余原样):

Running without API keys...
{"id":"1","type":"response","command":"get_state","success":true,"data":{"model":{…},
"thinkingLevel":"off","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time",
"followUpMode":"one-at-a-time","sessionId":"01a0ca53-…","autoCompactionEnabled":true,
"messageCount":0,"pendingMessageCount":0}}

第 3 步的三行输出(完整、未折叠):

{"type":"response","command":"parse","success":false,"error":"Failed to parse command: Unexpected token 'o', \"not json\" is not valid JSON"}
{"id":"2","type":"response","command":"no_such_command","success":false,"error":"Unknown command: no_such_command"}
{"id":"3","type":"response","command":"get_messages","success":true,"data":{"messages":[]}}

如何判断成功:① 每条响应都恰好占一行,且 id 与你送进去的对得上;② 坏 JSON 那条响应的 command 是字符串 "parse" 且没有 id(因为行都没解析出来,无从得知 id);③ 输出里没有任何非 JSON 的杂音(Running without API keys... 是 pi-test.sh:54 自己 echo 的,不是 pi 打的);④ 进程在 stdin 结束后自动退出,退出码 0。

观察题:上面被折叠的 data.model,本书这次实测展开后原样是:

{"id":"unknown","name":"unknown","api":"unknown","provider":"unknown","baseUrl":"","reasoning":false,
"input":[],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":0,"maxTokens":0}

去 packages/agent/src/agent.ts:57-68 看看这个 DEFAULT_MODEL 常量——从源码结构看,这说明没有凭证时模型没有被解析出来,Agent 状态里留的是占位模型;而 main.ts:909 的 !session.model 守卫检查的是「有没有值」,占位模型是有值的,所以没有拦住。

常见错误:① 忘了 PI_CODING_AGENT_DIR,会读到你自己 ~/.pi/agent/auth.json 里的真实凭证,输出就不是 unknown 了;② 用 fish 之类的 shell 直接写 VAR=x cmd 会报错,所以上面统一用 env;③ 在 Pi 仓库目录里跑会让它读到仓库自己的项目设置,务必 cd 到空目录;④ 少写了 --no-session 会在配置目录里留下会话文件(不影响正确性,但不够干净)。

对应源码位置:入口 main.ts:930-932;协议循环 rpc-mode.ts:810(挂读取器)→ rpc-mode.ts:752(handleInputLine,解析失败分支在 754-766)→ rpc-mode.ts:386(handleCommand,未知命令的 default 分支在 715-718);退出路径 rpc-mode.ts:804-807。

本章小结 ​

  • 四种模式(interactive / print / json / rpc)在 main.ts:845 共享同一个 AgentSessionRuntime,在 main.ts:930 才分发到各自壳层;--mode json 只是 runPrintMode 的输出变体(toPrintOutputMode,main.ts:124-126),不是第四种运行时。
  • RPC 协议 = 严格 JSONL(只按 \n 切帧,因此不能用 Node readline)+ 33 种入站命令 + 三类出站消息(命令响应、会话事件、扩展 UI 请求)+ 一条反向的 extension_ui_response 通道。
  • 线上的会话事件都经过 toJsonEvent:message_update 只发增量与当前用量,完整消息看 message_start / message_end。json 模式与 RPC 模式在这一点上完全相同。
  • prompt 的响应「一次且仅一次」,在预检通过时发出;之后的一切进展只在事件流里。
  • takeOverStdout 把所有非协议输出赶到 stderr,保证协议流洁净,并有 spawn 真进程的回归测试守着。
  • rpc-entry.ts 是 RPC 的独立入口,本质就是 main(["--mode", "rpc", ...])。packages/server 虽然名字像,却不是 RPC 的消费者:它和 pi-protocol / pi-client 一起服务于实验中的持久化 Session / Agent Harness,走 CBOR 长度前缀帧与版本握手,只能经 PI_EXPERIMENTAL=1 的开发入口触达,不在发布内容里。
  • 关键术语:壳层、JSONL(LF-only 帧格式)、可辨识联合(Discriminated Union)、背压(Backpressure)、扩展 UI 请求(反向 RPC)。
  • 关键源码索引:main.ts:111(resolveAppMode)、main.ts:845(runtime)、main.ts:930(分发)、rpc-mode.ts:54(runRpcMode)、rpc-mode.ts:394(prompt 语义)、rpc-mode.ts:752(handleInputLine)、rpc-types.ts:20(RpcCommand)、jsonl.ts:10(帧格式)、json-event.ts:48(toJsonEvent,线上事件改写)、print-mode.ts:108(json 输出)、rpc-entry.ts:13(RPC 独立入口)。相关测试:test/rpc-jsonl.test.ts:5、test/rpc-prompt-response-semantics.test.ts:185、test/print-mode.test.ts:93、test/stdout-cleanliness.test.ts:98。
  • 自测问题:① pi --mode text "hi" > out.txt 最终走进哪个函数?为什么?② 宿主收到一条不带 id 的 {"type":"response","command":"parse","success":false,...},说明发生了什么?③ 为什么 RPC 模式不读管道 stdin,而 print 模式读?④ 扩展在 RPC 模式下调 uiContext.select 时,那个 Promise 是被谁 resolve 的?
  • 下一章:6.11 SDK:把 Pi 当作库使用——不 spawn 子进程、直接在自己的 Node 程序里构造 AgentSession,以及官方给出的「什么时候用 SDK、什么时候用 RPC」选型建议。
  • 尚未展开的高级内容:RpcClient 的超时与重连细节、pi-protocol 的消息种类与路由规则、src/experimental/ 下的实验服务器与客户端、扩展 UI 九种 method 各自的语义(部分在 7.1 Extension 系统 展开)。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 走进 runPrintMode(main.ts:930 的分发处),输出变体是 text。因为 resolveAppMode 的判断顺序是「--mode rpc / --mode json 优先 → -p 或任一标准输入输出不是 TTY 就 print → 否则 interactive」:--mode text 不属于前两个分支,而 > out.txt 让 stdout 不再是 TTY,于是落到 print。注意就算不写 --mode text,这条命令也是同样的结果——重定向本身就足够了。
  2. 说明宿主发过去的那一行根本没解析成 JSON。handleInputLine 的解析失败分支(rpc-mode.ts:754-766)拿不到请求里的 id(连对象都没有,何来 id),只能发一条 command 为 parse 的失败响应。常见原因是把一条命令写成了多行 JSON——协议是严格 JSONL,只按 \n 切帧。
  3. 因为在 RPC 模式下 stdin 就是协议信道本身:上面跑的是一行一条的 RpcCommand(以及唯一的例外 extension_ui_response),谁也不能再把它当成「用户输入的一段文字」来读,否则协议就乱了。print 模式没有这个负担,管道 stdin 对它就是普通的用户输入来源(命令行参数 + 管道内容)。
  4. 被 handleInputLine 拦下的那条 extension_ui_response resolve 的。RPC 模式没有终端可弹对话框,于是 createExtensionUIContext 把 uiContext.select 改写成「往 stdout 发一条 extension_ui_request,然后 await 一个 Promise」;这个 Promise 登记在 pendingExtensionRequests 里,等宿主从 stdin 回一条同 id 的 extension_ui_response,handleInputLine 在交给 handleCommand 之前先把它截住并兑现(rpc-mode.ts:768-782)——这是 stdin 上唯一不是 RpcCommand 的东西。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)