Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

运行模式:Interactive / Print / JSON / RPC / SDK

TL;DR:Pi 的所有模式共用同一个 AgentSession,模式只决定三件事:输入从哪来、事件怎么输出、进程活多久。Interactive 把事件渲染成全屏 TUI;Print 只输出最终文本;JSON 把事件流写成 JSONL 后退出;RPC 是被宿主拉起的长生命周期子进程,stdin 收 JSONL 命令、stdout 回响应和事件,扩展的对话框也走这条通道;SDK 则不是 CLI 模式,宿主直接在自己的 Node/Bun 进程里 createAgentSession()。选型口诀:人用 Interactive,脚本要结果用 Print、要过程用 JSON,其他语言或要进程隔离用 RPC,TypeScript 宿主要完整控制用 SDK。

为什么能共用一个核心

主笔记里讲过,会话的全部产品逻辑都在 AgentSession 里:输入预处理、扩展钩子、上下文投影、持久化、压缩、重试。它对外只暴露两类接口:

  • 方法:prompt() / steer() / followUp() / abort() / compact() / setModel() / navigateTree() ……
  • 事件:session.subscribe(listener) 收到的 AgentSessionEvent(消息、工具执行、队列、压缩、重试、agent_settled 等)。

会话的新建、切换、分叉由外面一层 AgentSessionRuntime 负责。于是每个模式都只是一个“I/O 外壳”:把自己的输入翻译成方法调用,把事件翻译成自己的输出。main.ts 创建好 runtime 后按模式分发:

main.ts
  └─ createAgentSessionRuntime(createRuntime, ...)
       ├─ --mode rpc              → runRpcMode(runtime)
       ├─ 交互终端(默认)         → new InteractiveMode(runtime).run()
       └─ -p / --mode json / 管道 → runPrintMode(runtime, { mode: "text" | "json" })
SDK:宿主自己调 createAgentSession() / createAgentSessionRuntime(),不经过 main.ts

模式判断规则:--mode rpc → RPC;--mode json → JSON;显式 -p/--print 或者 stdin / stdout 任一不是 TTY → Print;否则 Interactive。所以 echo "总结一下" | pi 会自动走 Print。非交互模式还会接管 stdout,把普通日志改写到 stderr,保证 stdout 只有结果或协议记录。

每个模式都要处理一件共同的事:runtime 换了会话之后,旧 session 上的订阅就失效了。它们都通过 runtime.setRebindSession() 注册回调,在替换后重新 session.bindExtensions({...}) 并重新 subscribe()。bindExtensions 时各模式传入自己的 UI 能力和“命令上下文动作”(新建会话、fork、切换、reload 等),扩展通过 ctx.mode 知道自己运行在 tui / print / json / rpc 哪种外壳里。

一句话:会话核心和 I/O 外壳分离,模式之间只差输入来源、事件出口和进程生命周期。

各模式拿事件做什么

Interactive。 最重的外壳:全屏 TUI(1.0 起默认,tuiMode: "regular" 可回到普通滚动缓冲)、编辑器、快捷键、各种选择器(模型、会话树、设置)、diff 和工具执行渲染。它订阅 session 事件,把 message_update 增量渲染成流式文本,把 tool_execution_* 渲染成工具卡片,把 auto_retry_*、compaction_* 渲染成倒计时和提示。用户在 agent 运行时继续输入:Enter 发出的是 steer(当前回复及其工具调用结束后引导下一次回复),Alt+Enter 发出的是 follow-up(等整个任务做完);中止时队列里的消息退回编辑器。扩展可以用真实的终端 UI(自定义组件、footer、编辑器替换等)。它没有重写任何会话逻辑。

Print(text)。 一次性执行:创建 runtime → bindExtensions → 依次 await session.prompt(...) → 打印最后一条 assistant 文本 → 退出。中间事件不输出。退出码有讲究:最后的回复 stopReason 为 error 或 aborted 时错误写 stderr、退出码非 0;因输出 token 上限被截断(length)时仍打印部分文本,但在 stderr 警告并返回非 0。适合命令替换、管道、CI 里的一次性任务。

JSON。 同样是一次性执行,所有 prompt 在启动时给定,跑完就退出,不接受后续命令。区别在输出:先写一条会话 header,然后把每个 session 事件序列化成一行 JSON。为了让流的体积线性增长,线上的 message_update 只带增量(去掉了 SDK 事件里的累计 message 和 partial 快照),消费方应该用 delta 拼实时显示,再用 message_end 的权威消息替换。JSON 模式下失败的回复只体现在事件流里,不会单独让退出码变非 0,需要调用方自己看事件。它还订阅了底层 agent 事件来等待 stdout 背压,避免消费方读慢时内存堆积。

RPC 和 SDK 下面单独讲。

RPC:一条进程边界上的 JSONL 协议

RPC 解决的问题是:宿主不是 TypeScript,或者想把 agent 放进独立进程(崩溃隔离、权限隔离、IDE 插件、自定义前端),但又需要双向控制,而不是 Print/JSON 那种“一次输入、一次输出”。

启动方式是 pi --mode rpc(常配 --no-session),其他 CLI 选项照常决定 cwd、模型、工具和资源。进程是长生命周期的,宿主关闭 stdin 即请求有序退出,Pi 会先 dispose runtime。

协议由四类记录组成:

方向记录作用
stdin命令prompt、steer、follow_up、abort、get_state、set_model、compact、set_auto_retry、bash、switch_session、fork、get_tree 等
stdoutresponse某条命令是否成功及返回数据,带回命令的 id
stdout会话事件与 JSON 模式同一套事件(消息、工具、队列、压缩、重试、agent_settled)
双向扩展 UI 记录extension_ui_request / extension_ui_response

几个设计点值得在面试里讲:

响应和完成是两回事。 prompt 命令的成功响应只说明“已受理”,data.disposition 是 started(开始了一次 run)、queued(作为 steer/follow-up 排队)或 handled(被扩展命令或 input 处理器吃掉,不会有 run)。实现上 RPC 不 await session.prompt(),而是用 preflightResult 回调在预检通过时立即回响应,之后的进展全靠事件。客户端要一直读事件直到 agent_settled——agent_end 之后还可能有重试、溢出恢复、排队消息。provider 失败也不会变成失败响应,而是出现在消息和事件流里;失败响应只表示命令本身没处理成功(比如模型不存在、JSON 解析失败)。

按 id 关联,不按顺序。 命令处理是异步的,多个命令可以同时在途,所以每条命令带一个可选 id,响应原样带回。事件一般不带 id,因为它描述的是会话活动而不是某个请求;唯一例外是 RPC 专属的 bash_execution_update,它带回发起 bash 命令的 id,方便把输出块对应回去。

严格的 LF 分帧。 每条记录一行,只按 \n 切分。不能用 Node 的 readline,因为它还会在 U+2028 / U+2029 处断行,而这两个字符在 JSON 字符串里是合法的,模型输出里也真的会出现。stdout 只放协议记录,诊断一律走 stderr;Pi 尊重 stdout 背压,客户端不读 stdout 会让 Pi 卡住。

扩展 UI 也能跨进程。 扩展调用 ctx.ui.select / confirm / input / editor 时,RPC 模式把它变成一条 extension_ui_request(带唯一 id)写到 stdout,阻塞等待客户端在 stdin 回 extension_ui_response;带 timeout 的对话框超时由 Pi 一侧自动用默认值结束。notify、setStatus、setWidget、setTitle 等是只发不收的通知,客户端可以显示也可以忽略。依赖真实终端的能力(custom() 组件、自定义 footer/header、编辑器替换、主题切换)在 RPC 下是空操作或返回失败。此时 ctx.hasUI 为 true(对话框可用),想用 TUI 专属能力的扩展应判断 ctx.mode === "tui"。

有官方客户端。 TypeScript 宿主可以直接用导出的 RpcClient:它负责拉起子进程、解析 JSONL、关联请求和响应、提供类型化的命令方法,promptAndWait() 会在发送 prompt 之前先装好事件监听,避免快速完成时漏掉事件。

一句话:RPC 把 AgentSession 的方法映射成 stdin 命令、把事件原样搬到 stdout,响应只代表受理,完成以 agent_settled 为准。

SDK:在宿主进程里嵌入 Pi

SDK 不是一种 CLI 模式,而是同一个 npm 包的库用法:

import { createAgentSession } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession();
const unsubscribe = session.subscribe((event) => { /* 接自己的 UI */ });
try {
  await session.prompt("解释这个仓库的启动流程");
} finally {
  unsubscribe();
  session.dispose();
}

不传参数时,它和 CLI 一样使用当前目录、发现的资源、已存储的设置和凭据。每一条边界都可以替换:modelRuntime / model / thinkingLevel 控制模型,settingsManager 可以是内存配置,sessionManager 可以是 SessionManager.inMemory()(不落盘),resourceLoader 控制扩展、skills、模板、context files,tools / excludeTools / customTools 控制工具。需要 /new、切换、fork 时用 createAgentSessionRuntime(),并在替换后重新订阅新 session。

几个嵌入时容易踩的点:

  • await session.prompt() 在 run settle 之后才返回,包括自动重试和压缩恢复;agent 正在运行时再调 prompt() 必须指明 streamingBehavior,否则直接拒绝。
  • 会话树才是上下文权威:恢复外部历史要构造带这些条目的 SessionManager,给 session.agent.state.messages 赋值不会改变后续请求。
  • CLI 默认加载的内置扩展(codemode、tool_search、MCP)在 SDK 里不会自动加载,要自己加进 DefaultResourceLoader 的 extensionFactories;MCP 在 session_start 时连接服务器,所以还要调用 session.bindExtensions()。
  • 用完调用 session.dispose():中止进行中的工作、使扩展上下文失效、断开与 agent 的连接。

像 OpenClaw 这样以 Pi 为内核做自己产品的宿主,走的就是这条路:直接在自己的进程里创建会话、注入自己的工具和资源加载、订阅事件接到自己的界面或消息通道上,不需要再跨进程转协议。

什么时候选哪种

模式进程边界生命周期适合
Interactive本进程,终端 UI直到用户退出人直接使用
Print本进程,stdout 最终文本一次调用脚本只要最终答案:命令替换、管道、CI
JSON本进程,stdout JSONL 事件一次调用脚本要结构化过程:进度、工具调用、成本
RPC子进程,JSONL 双向长期非 TS 宿主、IDE、自定义前端、需要进程隔离
SDK无边界,同进程由宿主决定Node/Bun 宿主要完整 API 和最低开销

判断顺序可以这样说:先问“是不是人在用”,是就 Interactive;再问“要不要中途交互/控制”,不要就是 Print 或 JSON(看要结果还是要过程);要的话再问“宿主是不是 TypeScript 且接受同进程”,是就 SDK,否则 RPC。

常见追问

为什么 RPC 不用 HTTP 或 WebSocket? stdio 不需要端口、鉴权和服务发现,进程生命周期天然绑定宿主,任何语言都能 spawn 子进程并读写管道;Pi 本身也没有常驻服务的概念。代价是一条连接只服务一个宿主,并且宿主要自己处理子进程崩溃和超时。

RPC 和 JSON 模式的事件有什么区别? 是同一套事件和同一种增量编码。JSON 模式开头多一条会话 header;RPC 多了 response、bash_execution_update、extension_error 和扩展 UI 子协议,会话 id 要用 get_state 查。

SDK 和 RPC 都能用 TypeScript 驱动,怎么取舍? SDK 拿到的是完整对象和带累计快照的事件,能直接注入自定义工具和资源加载;RPC 拿到的是协议子集,但换来进程隔离——扩展或工具崩了不会拖垮宿主,也更容易限制权限。

交互模式里能用的扩展,在 Print/RPC 里一定能用吗? 不一定。扩展逻辑都能跑,但 UI 能力按模式降级:RPC 只支持对话框和通知,Print/JSON 没有 UI。扩展应该检查 ctx.hasUI 和 ctx.mode 再决定是否交互。

源码入口(v1.5):packages/coding-agent/src/main.ts、packages/coding-agent/src/modes/print-mode.ts、packages/coding-agent/src/modes/rpc/rpc-mode.ts、packages/coding-agent/src/modes/rpc/rpc-client.ts、packages/coding-agent/src/modes/interactive/interactive-mode.ts、packages/coding-agent/docs/rpc.md