6.10 交互模式与 RPC 模式
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:
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 循环)、工具、会话存储中的任何一行。
真实采集的帮助文本里,这件事只体现为一行选项(research/cli-captures/pi-help.txt:22,真实采集):
--mode <mode> Output mode: text (default), json, or rpc注意它的措辞是 Output mode(输出模式)——官方帮助自己就把 --mode 定位成「输出形式」而不是「运行时种类」。下面我们用源码验证这句话。
四种模式的真实分岔点
第一步:决定 appMode
resolveAppMode// 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
createAgentSessionRuntime// packages/coding-agent/src/main.ts:793-797
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: sessionManager.getCwd(),
agentDir,
sessionManager,
});这一行之前的所有工作——参数解析、设置合并、项目信任、会话管理器选择、模型解析、扩展(Extension)加载——四种模式走的是同一条路。从源码结构看,main.ts:561 到 main.ts:797 之间按 appMode 分岔的地方只有三处外围判断,而且都不改变构造出来的对象:非交互模式提前 takeOverStdout()(main.ts:592-596)、RPC 模式拒绝 @file 参数(main.ts:598-601)、只有交互模式才可能跑首次启动向导(main.ts:615-618)。
AgentSessionRuntime第三步:分发到壳层
runRpcMode// 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 对象,而不是四次独立构造。图中每个节点都标了真实源码位置:resolveAppMode 在 main.ts:109,runtime 在 main.ts:793 创建,分发在 main.ts:868,json 与 print 共用 runPrintMode,只是 mode 参数不同。
壳层各自做什么
从源码结构看,每个壳层都要回答同样的三个问题,只是答案不同:
| 问题 | interactive | print / json | rpc |
|---|---|---|---|
| 输入从哪来 | 键盘与 TUI 组件 | 命令行参数 + 管道 stdin | stdin 上的 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" 且没有 uiContext(packages/coding-agent/src/modes/print-mode.ts:71-109)。这个「模式名」会传给扩展,所以扩展能知道自己跑在哪种壳里。
RPC 协议精讲
帧格式:严格 JSONL,只认换行
serializeJsonLineRPC 协议的帧格式(framing)简单到只有一句话:一行一条 JSON,行分隔符只有 \n。但「只有 \n」这四个字是有代价的——读取端不能图省事用 Node 自带的 readline:
// 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 个用例全部通过)。
data 事件既可能只给你半行,也可能一次给你三行。必须自己维护缓冲区按 \n 切。packages/server/src/rpc-process.ts:63-79 就是一个独立实现的最小样例:把 chunk 追加进 stdoutBuffer,循环找 \n,切一行处理一行。 stdin:30 种命令
RpcCommand30 种命令不必背,挑五个有代表性的看结构就够:
| 命令 | 形状 | 说明 |
|---|---|---|
prompt | { id?, type, message, images?, streamingBehavior? } | 发起一轮对话,响应语义特殊(见下) |
get_state | { id?, type } | 取一份状态快照 RpcSessionState(rpc-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_update 带 id 字段(agent-session.ts:181),它是唯一一个把 id 带进事件的地方。
stdout:三类消息
runRpcMode// 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 的参数类型直接把三类出站消息写在了签名里:
- 命令响应
RpcResponse(rpc-types.ts:115-231):统一形状{ id?, type: "response", command, success, data? };失败分支是联合体的最后一支{ id?, type: "response", command: string, success: false, error: string }(rpc-types.ts:231)。 - 会话事件
AgentSessionEvent(packages/coding-agent/src/core/agent-session.ts:139-181):原样序列化,不加任何包装。这是 RPC 与 json 模式共用的同一套事件负载。 - 扩展 UI 请求
RpcExtensionUIRequest(rpc-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(writeRawStdout,output-guard.ts:85-93)。结果是:任何依赖、任何扩展、任何忘了删的 console.log 都污染不了协议流。仓库里有专门的回归测试守着这条线:packages/coding-agent/test/stdout-cleanliness.test.ts:87,它真的 spawn 一个 CLI 进程去验证 --mode json --help 时 stdout 一个字节的杂音都没有。
prompt 的「一次且仅一次」响应
其它命令都是「做完返回一个响应」,prompt 不是:
// 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:187(describe("RPC prompt response semantics"),覆盖预检失败、预检成功、流式中排队三种情形)。
双向:扩展 UI 请求
handleInputLine交互模式下扩展调 uiContext.confirm(...) 会弹一个 TUI 对话框。RPC 模式没有终端可弹,于是 createExtensionUIContext(rpc-mode.ts:135-310)把这些调用改写成「发一条 extension_ui_request 到 stdout,然后 await 一个 Promise」;这个 Promise 被登记在 pendingExtensionRequests 里(rpc-mode.ts:90-130 的 createDialogPromise),等宿主从 stdin 回一条同 id 的 extension_ui_response(rpc-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 parseArgs → main.ts:592 resolveAppMode → main.ts:793 createAgentSessionRuntime → main.ts:870 runRpcMode(runtime) → rpc-mode.ts:805 attachJsonlLineReader(process.stdin, …) → rpc-mode.ts:747 handleInputLine → rpc-mode.ts:385 handleCommand → rpc-mode.ts:397 session.prompt(...) → 之后进入 5.2 讲过的主链路。
反向的事件链路:session.subscribe 回调(rpc-mode.ts:354-359)→ output(event)(rpc-mode.ts:59-61)→ serializeJsonLine(jsonl.ts:10)→ writeRawStdout(output-guard.ts:85-93)→ 真正的 stdout。
json 模式:把同一批事件单向倒出来
getHeader// 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 "..." 的输出形态是:首行会话头,其后每行一个事件,发完 initialMessage 与 messages 就结束(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-802、365-379) |
| 能否改模型/分叉/压缩 | 不能 | 能,30 种命令 |
一句话:json 是单向事件流,rpc 是双向会话协议。前者适合「跑一次、把过程录下来」,后者适合「长期驱动一个 Agent」。runPrintMode 有独立单测:packages/coding-agent/test/print-mode.test.ts:93(describe("runPrintMode"))。
packages/coding-agent/docs/json.md 的输出形态描述与实现一致,但事件清单已经过期:json.md:11 把 AgentSessionEvent 指向 agent-session.ts#L102,本书锁定版本里它在 agent-session.ts:139;json.md:14-24 列出的联合分支缺少源码中已有的 agent_settled、entry_appended、session_info_changed、thinking_level_changed、bash_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。
它的形状是四层:
- CLI:
server serve | list | spawn | status | stop | rpc | rpc-stream(帮助文本packages/server/src/cli.ts:19-23)。 - 进程间通信(IPC):不是 HTTP、不是 WebSocket,而是 Unix 域套接字上的换行分隔 JSON——套接字路径
~/.pi/server/server.sock(packages/server/src/config.ts:67-69),编码函数encodeMessage = JSON.stringify + "\n"(packages/server/src/ipc/protocol.ts:130-132)。也就是说它把 Pi 的帧格式原样复用了一遍。 - 管理层:
ServerSupervisor.spawnInstance(packages/server/src/supervisor.ts:270-298)为每个工作目录拉起一个 pi RPC 子进程,用get_state同步会话元数据,实例记录持久化在~/.pi/server/instances.json。 - 子进程:
getSpawnCommand这里出现了本章最后一个入口文件:packages/coding-agent/src/rpc-entry.ts:1-12(全文 12 行),它做的事就是把 --mode rpc 硬编码进参数再调 main:
// 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 都当 RpcCommand 或 extension_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),但spawnInstance与RpcProcessInstance都没有把它们传给子进程,尚未确认是否为未完成的功能。
第三方集成为什么成为可能
4.1 提过第三方社区 Web 界面 agegr/pi-web。现在可以给出协议层面的解释了:任何语言、任何进程只要能做到三件事,就能驱动一个完整的 Pi——
- spawn 一个
pi --mode rpc子进程(或经 server 包的rpc-stream连过去); - 按 LF-only 规则切行、解析 JSON;
- 把
id对得上的response配对,其余当事件广播,extension_ui_request回一条extension_ui_response。
仓库里有两个现成的参考实现可以照抄:官方 TypeScript 客户端 RpcClient(packages/coding-agent/src/modes/rpc/rpc-client.ts:73-97 负责 spawn、507-526 就是上面第 3 条的 20 行版本),以及 server 包的 RpcProcessInstance(packages/server/src/rpc-process.ts:101-128)。RpcClient、runRpcMode、以及全部 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 后只能靠跑一遍集成测试来发现不兼容。
目标:在完全没有 API Key 的前提下,亲手驱动一次 RPC 模式,观察成功响应、解析失败响应、未知命令响应三种形态。
步骤与命令(把 ~/pi 换成你本地 Pi 仓库路径;所有命令都不写入 Pi 仓库):
# 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:747(handleInputLine,解析失败分支在 749-761)→ rpc-mode.ts:385(handleCommand,未知命令的 default 分支在 710-713);退出路径 rpc-mode.ts:799-802。
本章小结
- 四种模式(interactive / print / json / rpc)在
main.ts:793共享同一个AgentSessionRuntime,在main.ts:868才分发到各自壳层;--mode json只是runPrintMode的输出变体(toPrintOutputMode,main.ts:122-124),不是第四种运行时。 - RPC 协议 = 严格 JSONL(只按
\n切帧,因此不能用 Nodereadline)+ 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:109(resolveAppMode)、main.ts:793(runtime)、main.ts:868(分发)、rpc-mode.ts:53(runRpcMode)、rpc-mode.ts:393(prompt 语义)、rpc-mode.ts:747(handleInputLine)、rpc-types.ts:20(RpcCommand)、jsonl.ts:10(帧格式)、print-mode.ts:104(json 输出)、rpc-process.ts:50(server 拉起子进程)。相关测试:test/rpc-jsonl.test.ts:5、test/rpc-prompt-response-semantics.test.ts:187、test/print-mode.test.ts:93、test/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 系统 展开)。