Skip to content

6.10 交互模式与 RPC 模式

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题pipi -ppi --mode jsonpi --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@c13ffe1第 109–124 行在 GitHub 查看 ↗
模式决策与输出模式映射:显式 --mode 优先;-p 或任一标准输入输出不是终端(TTY)时进入 print;否则交互模式。toPrintOutputMode 把 json 折回 print 模式的输出变体。
ts
// packages/coding-agent/src/main.ts:109-124
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:79-83)。interactive 不是你能显式要求的模式,而是「什么特殊条件都不满足」时的默认值——这解释了为什么 pi --mode text 在终端里跑仍然会进交互模式(resolveAppMode 根本没有 text 分支)。

第二步:造出唯一的 runtime

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

这一行之前的所有工作——参数解析、设置合并、项目信任、会话管理器选择、模型解析、扩展(Extension)加载——四种模式走的是同一条路。从源码结构看,main.ts:561main.ts:797 之间按 appMode 分岔的地方只有三处外围判断,而且都不改变构造出来的对象:非交互模式提前 takeOverStdout()main.ts:592-596)、RPC 模式拒绝 @file 参数(main.ts:598-601)、只有交互模式才可能跑首次启动向导(main.ts:615-618)。

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

第三步:分发到壳层

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

三个函数的第一个参数都是同一个 runtime 对象。这就是本章开头那句论点的全部证据:差异只在壳层

图加载中…

图 6.10-1 四模式共享核心,只在壳层分叉
阅读顺序:自上而下。请特别关注中间那个收窄的瓶颈节点——四条出边全部来自同一个 runtime 对象,而不是四次独立构造。图中每个节点都标了真实源码位置:resolveAppModemain.ts:109runtimemain.ts:793 创建,分发在 main.ts:868jsonprint 共用 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:316-350),print 版绑 mode: "json" | "print" 且没有 uiContextpackages/coding-agent/src/modes/print-mode.ts:71-109)。这个「模式名」会传给扩展,所以扩展能知道自己跑在哪种壳里。

RPC 协议精讲

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

earendil-works/pi@c13ffe1第 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 行的 attachJsonlLineReaderjsonl.ts:21-58):只找 \n,顺手容忍行尾的 \r(兼容 CRLF),流结束时把缓冲区里没有换行结尾的最后一行也吐出来。

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

⚠️ 常见误解用 JSON.parse 直接吃整块 stdout
子进程的一次 data 事件既可能只给你半行,也可能一次给你三行。必须自己维护缓冲区按 \n 切。packages/server/src/rpc-process.ts:63-79 就是一个独立实现的最小样例:把 chunk 追加进 stdoutBuffer,循环找 \n,切一行处理一行。

stdin:30 种命令

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

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

命令形状说明
prompt{ id?, type, message, images?, streamingBehavior? }发起一轮对话,响应语义特殊(见下)
get_state{ id?, type }取一份状态快照 RpcSessionStaterpc-types.ts:95-108
abort{ id?, type }打断当前流式输出
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_updateid 字段(agent-session.ts:181),它是唯一一个把 id 带进事件的地方。

stdout:三类消息

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

output 的参数类型直接把三类出站消息写在了签名里:

  1. 命令响应 RpcResponserpc-types.ts:115-231):统一形状 { id?, type: "response", command, success, data? };失败分支是联合体的最后一支 { id?, type: "response", command: string, success: false, error: string }rpc-types.ts:231)。
  2. 会话事件 AgentSessionEventpackages/coding-agent/src/core/agent-session.ts:139-181):原样序列化,不加任何包装。这是 RPC 与 json 模式共用的同一套事件负载。
  3. 扩展 UI 请求 RpcExtensionUIRequestrpc-types.ts:238-273):九种 method(select / confirm / input / editor / notify / setStatus / setWidget / setTitle / set_editor_text),是唯一一类由 Pi 主动发起、需要宿主回话的消息。

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

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

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

ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:393-415
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:187describe("RPC prompt response semantics"),覆盖预检失败、预检成功、流式中排队三种情形)。

双向:扩展 UI 请求

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

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

图加载中…

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

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

完整调用链

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

cli.ts:20 main()main.ts:561 parseArgsmain.ts:592 resolveAppModemain.ts:793 createAgentSessionRuntimemain.ts:870 runRpcMode(runtime)rpc-mode.ts:805 attachJsonlLineReader(process.stdin, …)rpc-mode.ts:747 handleInputLinerpc-mode.ts:385 handleCommandrpc-mode.ts:397 session.prompt(...) → 之后进入 5.2 讲过的主链路。

反向的事件链路:session.subscribe 回调(rpc-mode.ts:354-359)→ output(event)rpc-mode.ts:59-61)→ serializeJsonLinejsonl.ts:10)→ writeRawStdoutoutput-guard.ts:85-93)→ 真正的 stdout。

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

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

于是 pi --mode json "..." 的输出形态是:首行会话头,其后每行一个事件,发完 initialMessagemessages 就结束(print-mode.ts:121-127),最后 disposeRuntime 并 flush(print-mode.ts:152-158)。

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

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

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

🌱 初学者提示文档漂移:以源码为准
官方文档 packages/coding-agent/docs/json.md 的输出形态描述与实现一致,但事件清单已经过期:json.md:11AgentSessionEvent 指向 agent-session.ts#L102,本书锁定版本里它在 agent-session.ts:139json.md:14-24 列出的联合分支缺少源码中已有的 agent_settledentry_appendedsession_info_changedthinking_level_changedbash_execution_update,也没体现 agent_end 已被覆写成带 willRetry 字段。相比之下 docs/rpc.md 与实现吻合度很高。遇到分歧时,以你亲自 Read 到的源码为准。

server 包:RPC 协议的第一个真实消费者

如果 RPC 协议真的好用,仓库里应该能找到一个自己吃自己狗粮的地方。它就是 packages/server

先说定位。官方说明写得很直白:包描述是 "experimental server package for pi"(packages/server/package.json:4),README 首段声明该包"may change or be removed without notice"(packages/server/README.md:3)。根 README 的包列表里根本没有它(README.md:26-35),文档站也没注册它的文档。并且它没有任何测试文件(源码事实:packages/server 下不存在 *.test.ts)。所以下面的内容请当作「一个协议使用范例」来读,而不是稳定 API。

它的形状是四层:

  1. CLIserver serve | list | spawn | status | stop | rpc | rpc-stream(帮助文本 packages/server/src/cli.ts:19-23)。
  2. 进程间通信(IPC):不是 HTTP、不是 WebSocket,而是 Unix 域套接字上的换行分隔 JSON——套接字路径 ~/.pi/server/server.sockpackages/server/src/config.ts:67-69),编码函数 encodeMessage = JSON.stringify + "\n"packages/server/src/ipc/protocol.ts:130-132)。也就是说它把 Pi 的帧格式原样复用了一遍。
  3. 管理层ServerSupervisor.spawnInstancepackages/server/src/supervisor.ts:270-298)为每个工作目录拉起一个 pi RPC 子进程,用 get_state 同步会话元数据,实例记录持久化在 ~/.pi/server/instances.json
  4. 子进程
earendil-works/pi@c13ffe1第 50–61 行在 GitHub 查看 ↗
server 如何拉起 pi:Bun 单文件二进制环境下执行同目录的 pi --mode rpc;Node 环境下直接 resolve 到 coding-agent 的 rpc-entry 导出。

这里出现了本章最后一个入口文件:packages/coding-agent/src/rpc-entry.ts:1-12(全文 12 行),它做的事就是把 --mode rpc 硬编码进参数再调 main

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

configureHttpDispatcher();

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

值得看的还有 rpc_stream 这个请求类型:普通请求一问一答后关连接,而 rpc_stream 把连接升级为长连流——先回一条 rpc_ready,之后这条 socket 的每一行 stdin 都当 RpcCommandextension_ui_response 转发给子进程,反方向把响应、事件、UI 请求逐行写回(packages/server/src/ipc/server.ts:68-135)。协议类型直接从 coding-agent 主包导入复用(packages/server/src/ipc/protocol.ts:1-7),RpcServerMessage 就是那四类消息加一条 rpc_ready 的并集(protocol.ts:116-121)。

server 包唯一的对外 HTTP 是出站的:向 Radius 服务(默认 https://radius.pi.dev/packages/server/src/radius.ts:8)注册本机与每个实例并按服务端下发的间隔发心跳(radius.ts:197-208)。注册时上报的能力字段是硬编码的 { rpc: true, relay: false, iroh: false }radius.ts:204)。

据此推断(尚未在源码中直接证实):server 包 + Radius 构成官方的「远程发现与中转」故事,云端某个界面据此找到你机器上的 pi 实例。尚未确认:Radius 服务端与其界面不在本仓库内,relay/iroh 两个能力恒为 false 说明直连通道尚未实现。另外 SpawnRequest 定义了 provider?/model? 字段(protocol.ts:14-15),但 spawnInstanceRpcProcessInstance 都没有把它们传给子进程,尚未确认是否为未完成的功能。

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

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

  1. spawn 一个 pi --mode rpc 子进程(或经 server 包的 rpc-stream 连过去);
  2. 按 LF-only 规则切行、解析 JSON;
  3. id 对得上的 response 配对,其余当事件广播,extension_ui_request 回一条 extension_ui_response

仓库里有两个现成的参考实现可以照抄:官方 TypeScript 客户端 RpcClientpackages/coding-agent/src/modes/rpc/rpc-client.ts:73-97 负责 spawn、507-526 就是上面第 3 条的 20 行版本),以及 server 包的 RpcProcessInstancepackages/server/src/rpc-process.ts:101-128)。RpcClientrunRpcMode、以及全部 RPC 类型都从主包入口导出(packages/coding-agent/src/index.ts:329-344),所以 Node 宿主不必去挖内部路径。

需要如实标注的边界:本书没有分析 pi-web 的源码,它与本书锁定版本的兼容性尚未确认(见 继续阅读与参考资料)。另一个尚未确认的点是跨版本兼容策略:RPC 协议本身没有 hello 或 version 消息,RpcClient.start() 甚至只是固定 sleep 100 毫秒等子进程起来(rpc-client.ts:131-132),没有 ready 握手。一种看法是「协议越简单越好,版本协商交给调用方锁版本」,代价是宿主升级 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":"019fb81e-…","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:47-58 看看这个 DEFAULT_MODEL 常量——从源码结构看,这说明没有凭证时模型没有被解析出来,Agent 状态里留的是占位模型;而 main.ts:852!session.model 守卫检查的是「有没有值」,占位模型是有值的,所以没有拦住。

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

对应源码位置:入口 main.ts:868-870;协议循环 rpc-mode.ts:805(挂读取器)→ rpc-mode.ts:747handleInputLine,解析失败分支在 749-761)→ rpc-mode.ts:385handleCommand,未知命令的 default 分支在 710-713);退出路径 rpc-mode.ts:799-802

本章小结

  • 四种模式(interactive / print / json / rpc)在 main.ts:793 共享同一个 AgentSessionRuntime,在 main.ts:868 才分发到各自壳层;--mode json 只是 runPrintMode 的输出变体(toPrintOutputModemain.ts:122-124),不是第四种运行时。
  • RPC 协议 = 严格 JSONL(只按 \n 切帧,因此不能用 Node readline)+ 30 种入站命令 + 三类出站消息(命令响应、会话事件、扩展 UI 请求)+ 一条反向的 extension_ui_response 通道。
  • prompt 的响应「一次且仅一次」,在预检通过时发出;之后的一切进展只在事件流里。
  • takeOverStdout 把所有非协议输出赶到 stderr,保证协议流洁净,并有 spawn 真进程的回归测试守着。
  • packages/server 是 RPC 协议的第一个消费者(Unix 套接字 + 子进程管理 + Radius 心跳),但它是 experimental 且零测试,只宜当范例读。
  • 关键术语:壳层JSONL(LF-only 帧格式)可辨识联合(Discriminated Union)背压(Backpressure)扩展 UI 请求(反向 RPC)
  • 关键源码索引:main.ts:109resolveAppMode)、main.ts:793(runtime)、main.ts:868(分发)、rpc-mode.ts:53runRpcMode)、rpc-mode.ts:393(prompt 语义)、rpc-mode.ts:747handleInputLine)、rpc-types.ts:20RpcCommand)、jsonl.ts:10(帧格式)、print-mode.ts:104(json 输出)、rpc-process.ts:50(server 拉起子进程)。相关测试:test/rpc-jsonl.test.ts:5test/rpc-prompt-response-semantics.test.ts:187test/print-mode.test.ts:93test/stdout-cleanliness.test.ts:87
  • 自测问题:① 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 的超时与重连细节、server 包的实例状态机与崩溃恢复、Radius 注册与心跳的完整协议、扩展 UI 九种 method 各自的语义(部分在 7.1 Extension 系统 展开)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)