同一场运行怎样投影成文本与 JSON
沿着 CLI mode 选择、PrintMode 订阅与 stdout takeover,解释一次性运行怎样分别输出最终文本或实时 JSONL 事件。
第 38 章把 AgentSession 交给 SDK 调用者,输入、事件订阅和退出时机都由宿主决定。命令行脚本常常不需要这么多控制:给一段 prompt,等 Pi 跑完,把结果交给下一个进程。Pi 为这种场景保留了 text 与 JSON 两种投影,但没有为它们另造一套 Agent。
resolveAppMode() 先看显式的 --mode rpc、--mode json,再看 -p 或 stdin/stdout 是否为 TTY。显式 JSON 进入 json app mode;-p 和非 TTY 环境进入 print;两者在最终 dispatch 时都调用 runPrintMode(),只把 output mode 分别设成 json 或 text。模型选择、资源加载、AgentSessionRuntime 的组装在此之前已经完成。
flowchart LR
accTitle: 同一套 runtime 的两种一次性输出投影
accDescr: CLI 在 runtime 创建后选择 text 或 JSON 投影;两者顺序执行 prompts,区别只在 session events 与最终消息怎样写入 stdout,结束时都释放 runtime。
INPUT["CLI 参数与 TTY 状态"] --> MODE["resolveAppMode"]
MODE --> RUNTIME["AgentSessionRuntime"]
RUNTIME --> PROMPTS["依次 await session.prompt"]
PROMPTS --> TEXT["text: 最后一条 assistant 的 text blocks"]
PROMPTS --> JSON["json: header + live session events"]
TEXT --> EXIT["dispose runtime / flush stdout"]
JSON --> EXIT
这张图里的“同一场运行”指执行组件与状态机相同,不是说一个进程同时产生两份结果。一次 invocation 只选一种投影。
JSON 输出是一段事件流
runPrintMode() 在 JSON 分支先读取当前 SessionManager 的 header;存在时,把它作为第一行写出。接着 rebindSession() 绑定 extension mode,取消旧订阅,再订阅当前 session。此后每个 AgentSessionEvent 都按一行一个 JSON 对象写到 stdout。
这里有两个边界。
第一,header 不是“最终响应”的 envelope。它是可选的会话元数据,后面的行才是 prompt 运行期间陆续发出的事件。消费者不能假定整个 stdout 只含一个 JSON,也不能把第一行的字段结构套到后面的事件上。
第二,这段代码没有遍历旧 entries 或旧 messages。订阅是在运行 prompts 之前建立的,所以它会看到随后产生的 live events,却不会为了 JSON mode 回放恢复会话里早已发生的事件。需要完整历史时,应读取 session tree 对应的接口或文件;不能把这次 JSONL stdout 当作天然的历史导出。
多个 prompt 也不是并发提交。initialMessage 完成后,for ... of 才发送 messages 中的下一条。JSON 消费者会因此看到多段连续的 Agent 事件;它若只按最后一个 message_end 判断整场命令结束,可能会在下一条 prompt 开始前过早收口。进程退出或完整的 settled/event 顺序才是更可靠的外层边界。
Text 只拿最后一条 assistant 消息
Text 分支在所有 prompt 都 await 完成后读取 session.state.messages 的最后一项。只有它的 role 是 assistant 才继续输出;然后遍历 content,只写 type === "text" 的 block。thinking、tool call、tool result、前面 prompt 的回答都不会进入这个最终 stdout。
最后一条 assistant 的 stopReason 若为 error 或 aborted,错误信息写到 stderr,退出码设为 1。若状态最后一项不是 assistant,这段实现不会为它补造错误文本,stdout 可以为空,原始 exitCode 仍保持 0。自动化脚本若要求必有答案,应自己检查空输出,而不是把进程成功等同于拿到了 assistant 文本。
所以 -p 适合 shell substitution、CI 中的小型生成步骤和只关心最终文字的调用者。它有意丢掉过程信息。想统计 token、观察 tool execution 或区分 retry,应选 JSON 事件流,不能事后从最终文本反推。
stdout 为什么没有被普通日志挤坏
JSONL 协议最怕中间混进一行 console.log("loading...")。Pi 在非 interactive mode 接管 stdout:保存原始 stdout/stderr writer,然后把当前进程后续经过 process.stdout.write 的普通写入改送 raw stderr。真正的 text、JSON 和 RPC 协议输出走 writeRawStdout(),使用接管前保存的 writer。
writeRawStdout() 还把写操作串进一条 Promise tail,并处理临时 backpressure 错误;flushRawStdout() 等这条 tail 清空。这个保护的具体对象是当前 Node 进程的 process.stdout.write。它说明 Pi 怎样隔开协议输出与常规日志,不等于任意外部进程直接写文件描述符时也会被自动重定向。
结束时没有下一轮命令入口
PrintMode 的生命周期很短。它注册 SIGTERM,非 Windows 还注册 SIGHUP;信号到来时清理 tracked detached children 并释放 runtime。正常路径、prompt 异常和错误 assistant 最终都进入 finally,移除 signal handlers、调用 runtimeHost.dispose(),再 flush raw stdout。CLI 随后恢复原来的 stdout writer。
可以用两个层次复核这个合同。固定源码副本没有安装依赖时,先做只读检查:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/coding-agent/src/modes/print-mode.ts |
nl -ba | sed -n '103,158p'
git -C "$repo" show v0.83.0:packages/coding-agent/src/core/output-guard.ts |
nl -ba | sed -n '45,108p'
在已经安装 Pi、配置好 provider 凭证的环境里,再把两个通道分开保存:
pi -p "只回复 OK" >answer.txt 2>text-diagnostic.log
pi --mode json "只回复 OK" >events.jsonl 2>json-diagnostic.log
wc -l answer.txt events.jsonl
jq -r '.type // "session_header"' events.jsonl
预期不是两份等价文件。answer.txt 只保留最后 assistant 的文本块;events.jsonl 由可选 header 和本次运行的事件组成。若命令提供多条 message,text 仍只取最终 state 的最后一条 assistant,而 JSON 会保留各段运行过程。
一次性投影适合管道,却无法在进程运行后继续发 steer、abort、switch_session 或状态查询。第 40 章保留相同的 runtime,把 stdin 变成命令通道、stdout 变成 response 与 event 的复用通道;随之而来的问题也变了:收到 prompt 的成功回执,到底是已经接单,还是已经完成?