RPC 模式如何把会话变成可控制进程
从严格 JSONL framing、prompt preflight 回执、session rebind 与 RpcClient,拆开宿主怎样控制一个长驻 Pi 子进程。
第 39 章的 print/JSON mode 会依次跑完启动参数里的 prompts,然后释放 runtime。它的 stdout 可以给程序消费,程序却没有一条持续存在的反向通道。RPC mode 补上这条通道:Pi 进程持有 AgentSessionRuntime,宿主向 stdin 写 command,进程把 response、session event 和部分 extension UI request 写到 stdout。
这仍是同一个 Coding Agent。runRpcMode() 拿到的参数不是新定义的 RPC Agent,而是已经组装好的 AgentSessionRuntime。进程边界改变了控制方式,也让三个时间点必须分开:命令已经收帧、命令已经被接受、这一轮 Agent 已经 settled。
flowchart LR
accTitle: RPC 子进程里的命令、回执与事件
accDescr: 宿主经 stdin 发送带 id 的 JSONL command;RPC process 调用当前 AgentSession,response 与运行事件可交错写回 stdout;session replacement 后重新绑定订阅。
HOST["宿主程序"] -->|"stdin: command + id"| FRAME["strict JSONL reader"]
FRAME --> DISPATCH["handleCommand"]
DISPATCH --> RUNTIME["AgentSessionRuntime"]
RUNTIME --> SESSION["current AgentSession"]
DISPATCH -->|"response + same id"| OUT["raw stdout JSONL"]
SESSION -->|"AgentSessionEvent"| OUT
OUT --> HOST
RUNTIME -->|"new / switch / fork replacement"| REBIND["rebind extension + subscriptions"]
REBIND --> SESSION
一行协议只认 LF
RPC 没用 Node readline。serializeJsonLine() 把对象 JSON.stringify 后追加一个 \n;reader 用 StringDecoder 拼接 UTF-8 chunk,只搜索 LF。行尾若有 CR 会剥掉,stdin 在没有结尾 LF 的情况下结束时,buffer 中最后一行仍会被交给 handler。
这样做是为了不把 JSON 字符串里合法的 U+2028、U+2029 当成 record separator。宿主实现 parser 时也应只按 LF 分帧,再对每一帧 JSON.parse。把“看起来像换行的 Unicode 字符”都当分隔符,会把一个合法 JSON 字符串切成两条坏消息。
收帧之后,handleInputLine() 只先做 JSON.parse,随后把对象 cast 成 RpcCommand 并进入 switch。TypeScript union 描述了合法 command,但不会校验来自 stdin 的实际字节。未知 type 会得到 error response;语法错误则得到 command 为 parse 的 error。宿主不能把编译期类型当成线上的 schema validator。
prompt 回执停在 preflight
大多数 command 由 handleCommand() await 对应 session 方法,再返回 response。prompt 是特例。RPC 调用 session.prompt() 后不 await 整个 Promise,而是在 options 里传入 preflightResult:回调收到成功时,立即输出 {type:"response", command:"prompt", success:true};若 preflight 成功前 Promise 就 reject,才输出 error response。handler 自身返回 undefined,避免通用分支再写第二份回执。
因此,prompt response 的含义是这条输入已经通过 prompt preflight,或者已经按其即时处理路径被接受。此时模型可能还在 streaming,工具可能还没执行,retry、compaction 和 follow-up 也可能继续发生。运行完成的信号来自事件流中的 agent_settled。
AgentSession 在 _runAgentPrompt() 的 finally 中清理 system prompt override、flush pending bash messages,再调用 _emitAgentSettled()。这个位置位于 agent prompt 和 post-run retry、compaction、queued continuation 循环之后。它比 agent_end 更适合作为 RPC 宿主的“本轮已经闲置”边界。
内置 RpcClient 也按这个语义写:prompt() 只等待 response;waitForIdle() 监听 agent_settled;promptAndWait() 先建立 event collector,再发送 prompt,避免快速完成的事件从订阅空隙里漏掉。
response 和 event 本来就会交错
协议类型允许 command 的 id 省略,内置 client 则始终生成 req_1、req_2 这样的 id,把 resolver 存进 pendingRequests。收到 type === "response" 且 id 命中 pending map 时,才解决对应 Promise;其他对象进入 event listeners。请求等待 response 的默认 timeout 是 30 秒。
直接向 runRpcMode() 写 stdin 时,并不存在一个覆盖所有 command 的串行队列。JSONL reader 每得到一行就 void handleInputLine(line),不会 await 上一行处理完成;而 prompt 内部又主动让完整运行留在后台。两个命令的 response、session events、extension UI requests 可以交错出现。稳定宿主应给每条 command 带唯一 id,用 id 关联 response,并单独按 event type 维护运行状态,不能依赖“输入第 N 行必然对应输出第 N 行”。
这里还有一处容易写错的时序:如果宿主先 await prompt(),再开始监听 settled,极快的 prompt 可能已经把事件发完。内置 promptAndWait() 先订阅就是为了关闭这个窗口。
便捷 client 的读取分流很薄:只有 type === "response" 且 id 命中 pending map 的对象会解决请求,其余合法 JSON 一律 cast 成 AgentSessionEvent 交给 listeners,坏 JSON 则忽略。extension_ui_request 在这里没有专门分支。需要实现 dialog 往返的宿主,不能只信 RpcEventListener 的静态类型,还要按完整 RPC union 识别这类对象并回送对应的 extension_ui_response。
session replacement 要换掉旧订阅
RPC mode 保存一个局部 session 变量,却不假设它永远指向启动时的对象。它向 AgentSessionRuntime 注册 setRebindSession();runtime 的 replacement 收尾会调用这个 callback,然后才把替换结果交还上层。rebindSession() 重新读取 runtimeHost.session,给新 session 绑定 RPC extension actions,取消旧 session 与旧 agent 的订阅,再接入新事件流和 stdout backpressure。
固定版本里还有一处重复调用:runtimeHost.newSession() 内部已经经过 finishSessionReplacement() 和上述 callback,RPC 的 new_session case 成功返回后又显式 await rebindSession()。第二次调用会再次取消刚建立的订阅并重新绑定,它不是第二次创建 session。读调用链时应把这个实现细节与 replacement 本身分开。
这份重绑决定了谁拥有状态。RPC transport 负责 command 和 event 的投影,AgentSessionRuntime 负责当前 session 的替换,真正的 message/tool/queue 状态仍在 child process 内当前的 AgentSession。宿主缓存的 state 只是事件投影,需要在重连或会话切换后重新查询,不能反过来覆盖 runtime authority。
Headless 只实现能跨进程表达的 UI
Extension API 在 RPC mode 中并非全部失效。select、confirm、input、editor 会发 extension_ui_request,宿主用同一个随机 id 回 extension_ui_response;notify、status、字符串数组 widget、title 和 editor text 也有可传输的 request 表示。
依赖 TUI component 或同步本地状态的方法则有明确降级:raw terminal input 返回空 unsubscribe;working indicator 不处理;component factory widget 被忽略;custom header/footer、custom UI、autocomplete 和 theme switching 不可用;getEditorText() 只能返回空字符串。一个 extension 在 interactive mode 正常,不代表换成 RPC 后还能得到等价 UI。
子进程退出就是一次控制权丢失
RpcClient.start() 用 node dist/cli.js --mode rpc 启动 child,stdin/stdout/stderr 全部 pipe。child 的 exit、error 或 stdin error 会记录 exitError,并 reject 当前 pending requests。后续 send 也会检查 child 是否仍存在、exitCode 与 stdin writable 状态。这个 client 没有在进程退出后自动重启、重放未完成 command 或恢复 event cursor。
RPC process 自身在 stdin end 时 shutdown;信号或 extension shutdown request 也会进入清理路径。清理会取消订阅、dispose runtime、detach input、pause stdin,并在适用时 flush raw stdout。session JSONL 可能已经保存完成的 message,这不等于退出前仍在运行的 request 可以从原位置续接。
固定 tag 的 framing 可以在不安装依赖的情况下只读核对:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/coding-agent/src/modes/rpc/jsonl.ts |
nl -ba | sed -n '4,58p'
git -C "$repo" show v0.83.0:packages/coding-agent/src/modes/rpc/rpc-mode.ts |
nl -ba | sed -n '384,415p;747,816p'
宿主侧的最小等待逻辑应先订阅、后发 prompt,并把 response 与 completion 分开:
const settled = client.collectEvents(60_000)
await client.prompt('检查当前目录,只报告发现的问题') // preflight accepted
const events = await settled // resolves on agent_settled
上面只是时序骨架;真实宿主还要处理 extension UI request、timeout、child exit 和 stderr diagnostics。固定源码副本没有依赖安装,本章不把源码阅读写成已完成的在线模型运行测试。
至此,RPC 已经让一个 child process 可被程序控制,但它只管理自己这一份 runtime。多个实例怎样登记、查询、停止,父进程重启后哪些记录还能相信,是更上一层的生命周期问题。第 41 章沿 pi-server 的 supervisor 继续追:它到底拥有 Agent,还是只拥有承载 Agent 的进程与元数据。