跳转到内容

08. Pi 子进程桥

Pi 路径由两个组件组成:主进程里的 PiAgent 与独立可执行的 pi-agent-server。两者通过 stdin/stdout JSONL 通讯;权限、MCP 连接和产品状态留在父进程,Pi SDK session/agent loop 留在子进程。

主文件:

子进程顶部注释给出直接动机:Pi SDK 是 ESM、依赖较重、打包复杂,隔离能避免 Electron main bundle 冲突。更深层收益还有:

  1. 故障隔离:Pi runtime 崩溃不直接击穿 SessionManager。
  2. 版本/模块隔离:可以单独构建 dist/index.js 与 runtime resolver。
  3. 强制终止:abort 无效时父进程可 kill child。
  4. stdout 协议纪律:只有 JSONL,调试走 stderr,避免 parser 被日志污染。
  5. 秘密边界:MCP credential/client 留父进程,子进程只拿必要 auth 与 proxy 调用。

代价是要实现一套小型双向 RPC、correlation map、超时、崩溃恢复和协议兼容。

flowchart LR
SM["SessionManager"] --> PA["PiAgent\nparent adapter"]
PA -->|"stdin JSONL"| PS["pi-agent-server"]
PS -->|"stdout JSONL"| PA
PS -->|"stderr logs"| LOG["debug ring/file"]
PS --> PI["Pi AgentSession"]
PA --> CHECK["central pre-tool checks"]
PA --> MCP["McpClientPool"]
PA --> CB["session/browser callbacks"]

PiAgent 仍继承 BaseAgent,所以 SessionManager 看见的接口与 Claude 完全相同。

子进程定义 inbound union 于 index.ts,outbound union 于 index.ts

消息 作用
init workspace/session/model/auth/system/runtime 初始配置
prompt 用户消息、图片、system prompt、turn 配置
register_tools 完整/增量 proxy tool 定义
pre_tool_use_response 中央检查后的 allow/block/modified input
tool_execute_response MCP/session proxy 执行结果
abort 请求停止当前生成
steer 原生中途转向
mini_completion 小模型文本调用
llm_query 完整 call_llm 请求
ensure_session_ready branch preflight/拿 session id
set_model / set_thinking_level 轻量运行时更新
update_runtime_config endpoint/provider/model registry 更新
compact / set_auto_compaction 手动/自动压缩控制
token_update OAuth token 刷新后热替换
shutdown 释放并退出
消息 作用
ready init 完成,包含 session/runtime 信息
event Pi SDK event,父进程再适配为 AgentEvent
pre_tool_use_request 请求中央安全管道决策
tool_execute_request 执行父进程拥有的 proxy tool
session_tool_completed session tool 的产品侧完成信号
mini_completion_result / llm_query_result correlation response
ensure_session_ready_result branch preflight 结果
compact_result / toggle/update result 管理命令返回
session_id provider session ID 更新
error transport/runtime error

每个 request/response 类型带 request id;父子分别维护 pending maps。

PiAgent.ensureSubprocess() 位于 pi-agent.ts,实际 spawn 从约 425 开始:

resolve packaged/dev pi-agent-server path
→ resolve Node/Bun runtime
→ 组 env(provider auth、debug、session vars)
→ 可选 --require network interceptor bundle
→ spawn child with pipes
→ stdout readline(JSONL)
→ stderr 捕获但绝不回 stdout
→ exit/error handler
→ send init
→ wait ready with timeout
→ register tools

统一网络 interceptor 只注入 Pi subprocess 路径,用于兼容 endpoint/SSE/tool-call 修整等。它必须在模块加载前通过 runtime preload 生效,不能等进程启动后 import。

父进程不能 spawn 后马上发 prompt;要等待 ready。否则 init/model registry/auth storage 尚未建立,会出现竞态。ensureSubprocess() 把并发调用合并到同一个启动 Promise,避免 title generation 和 chat 同时 spawn 两个 child。

子进程 send() 只做一行 JSON + newline;所有 debug 写 stderr。原因很朴素:任意第三方库 console.log 都会变成无法解析的“协议帧”。

父进程仍应容错:

  • 忽略空行;
  • JSON parse 错误记录原始片段;
  • stderr 保存最近内容,child 异常退出时附到诊断;
  • 单个坏帧不把所有 pending promise 永久悬挂。

入口:pi-agent-server/index.ts。它负责:

  • 初始化 Pi auth/model registry;
  • resolve model/custom endpoint;
  • 创建 Pi built-in Read/Write/Edit/Bash/Glob/Grep 等工具;
  • 加入 web search/fetch;
  • 为父进程 proxy tools 构造 wrapper;
  • 设置 session manager/continue/fork;
  • 应用 thinking、auto-compaction、system prompt override;
  • 订阅 AgentSessionEvent

Pi SDK 在 session.prompt() 时可能重建 system prompt,因此代码使用专门 override 机制,而不是只修改一次 state.systemPrompt

Parent PiAgent.chatImpl() 位于 pi-agent.ts。简化流程:

sequenceDiagram
participant SM as SessionManager
participant PA as PiAgent
participant PS as pi-agent-server
participant PI as Pi SDK
SM->>PA: chat(message, attachments)
PA->>PA: ensureSubprocess + context split
PA->>PS: prompt(systemPrompt, message, images)
PS->>PS: waitForCompaction
PS->>PI: session.prompt(..., followUp)
PI-->>PS: AgentSessionEvent*
PS-->>PA: event JSONL*
PA-->>SM: AgentEvent*
PI-->>PS: agent_end
PS-->>PA: event(agent_end)
PA-->>SM: complete

若 session 已 streaming,子进程用 streamingBehavior: 'followUp',避免 Pi SDK 抛“already processing”。Craft 的外层 queue/steer 仍由 SessionManager 决定,followUp 是 SDK 内部安全调用方式。

stdout reader 是长期存在的,chat() generator 却按 turn 存在。PiAgent 用 EventQueue 把进程事件送给当前 generator:

  • event 进入队列;
  • generator await 下一项;
  • agent_end/terminal error 关闭本 turn;
  • source activation 等 callback 可能触发额外事件;
  • child exit 会以 error 终止所有 pending。

不能直接在 stdout callback 中 yield,EventQueue 是 callback world 到 async iterator world 的桥。

Pi 子进程把较原生的 AgentSessionEvent 发给父进程,父进程适配:

  • message update/delta → text_delta
  • assistant end → text_complete
  • tool execution start/end → tool events;
  • agent end → complete/usage;
  • compaction/retry 状态 → info/status;
  • provider entry id → pi_turn_anchor

父进程在 pre-tool request 时捕获的 intent/display metadata 会在后续 tool event 上补回,再交 SessionManager/UI。

Pi 内置工具可在 subprocess 执行,但 source/session/browser 工具由 parent 拥有。

sequenceDiagram
participant PI as Pi tool wrapper
participant PS as Subprocess
participant PA as PiAgent parent
participant Pool as MCP/session/browser
PI->>PS: execute(proxyName, args)
PS-->>PA: tool_execute_request(id, name, args)
PA->>Pool: execute
Pool-->>PA: content/isError
PA-->>PS: tool_execute_response(id, result)
PS-->>PI: tool result

父进程的 pending map 把 requestId 对应到 resolver。Abort/shutdown 必须 resolve/reject所有 pending,否则 Pi SDK tool promise 永久挂起。

sequenceDiagram
participant PI as Pi tool
participant PS as Subprocess hook
participant PA as PiAgent
participant C as Central checks / UI
PI->>PS: before execute
PS-->>PA: pre_tool_use_request(id, tool, input)
PA->>C: runPreToolUseChecks
C-->>PA: allow/block/modified input
PA-->>PS: pre_tool_use_response(id,...)
PS-->>PI: execute or deny

这保证 safe/ask/allow-all 与 Claude 一致。对 parent proxy tools,某些检查可在父进程直接认定已由 proxy handler保护,但不能绕过需要的 mutation 分类。

register_tools 把新定义按 name merge。若 Pi session 已存在,不会在 generation 中立即 dispose;只设置 toolsChanged = true,下一次 prompt 前重建 session。

源码:handleRegisterTools

原因:Pi SDK 的 tool set 在 AgentSession 构造时确定,中途更换容易破坏当前 tool call;延迟到 turn boundary 是安全切点。

PiAgent.redirect() 发送 {type:'steer', message} 并返回 true;子进程调用 piSession.steer(message),见 processMessage

事件继续从同一个 generator 流出,因此 SessionManager 不应再 abort + 新建 turn。Pi 默认 midstream behavior 为 steer,正是利用这个原生能力。

如果 child/session 尚未 ready,父进程必须返回失败或发 steer_undelivered,不能把“写进 stdin”当作已被模型接收。

Craft 保存每个 assistant message 对应的 Pi entry ID/turn anchor。创建 branch 时:

  1. 复制 cutoff 前 Craft messages;
  2. 复制 parent anchor sidecar;
  3. init 告知 parent session 路径/ID/anchor;
  4. 子进程的 Pi SessionManager 调 native branch;
  5. ensure_session_ready 返回新 session id;
  6. 上层成功后才 announce child。

PiAgent.ensureBranchReady() 位于 pi-agent.ts

Provider entry ID 是硬截止的依据,不能用 Craft message timestamp 猜。

Pi SDK 自己在 overflow 时 _runAutoCompaction 并继续一次。Wrapper 不再平行调用 session.compact(),因为两个 AbortController/compaction 状态会竞态。

Parent 识别 slash command,发 compact request。子进程:

ensureSession
→ waitForCompaction(等待自动压缩排空,带 timeout)
→ session.compact(customInstructions)
→ 返回 summary / firstKeptEntryId / tokensBefore

源码:handleCompact

压缩必须串行化;它会修改 session entry graph,和 prompt/abort 并发会损坏 SDK 内部状态。

OAuth token 可能在 parent credential manager 刷新。token_update 将新 credential 写入子进程内的 Pi auth storage,并同步 init config;无需销毁整个 Craft session。

如果某 provider 的 auth client 在构造时捕获 token 而不重新读取 storage,则仍可能要求 runtime restart;热更新能力要以真实 SDK 行为为准。

update_runtime_config 合并 model/provider/auth/base URL/custom models:

  • custom endpoint 可动态注册未知 model;
  • active session 通过 setModel() 切换;
  • interceptor 同步 API hints;
  • 无 active session 时只保存,下一次构造应用;
  • 失败返回 {success:false, updated:false} 给 parent 决定重建。

源码:handleUpdateRuntimeConfig

完整 LLMQueryRequest 通过协议原样传递,子进程注释强调“传递字段”和“queryLlm 真正使用字段”是两个独立不变量。

对无副作用/只读 call_llm,代码可提前 speculative prefetch,等正式 tool call 时命中 cache,减少串行 latency。Cache 必须:

  • 只用于确定性足够的匹配 key;
  • abort/shutdown 清理;
  • mutation tool 禁用;
  • 未消费结果不能影响主状态。
  • 发 shutdown;
  • 短暂等待 graceful exit;
  • 超时 kill;
  • 关 readline/stdin;
  • reject pending maps;
  • 清 EventQueue 和 callback。

handleShutdown

  • unsubscribe Pi events;
  • dispose session;
  • stop callback server;
  • pending pre-tool 全 block;
  • pending tool execution 返回 error;
  • exit 0。

父进程要区分用户 abort、正常 shutdown 和 crash;crash 应携 stderr tail 形成 typed error,并把下一次 chat 置于可冷启动状态,而不是保留一个已死 child 引用。

优点:

  • provider 依赖和崩溃隔离;
  • native steer/branch/compaction 得到保留;
  • parent 统一权限、MCP、凭据;
  • 协议可记录、重放和独立测试。

代价:

  • 一次 tool call 多两次序列化与跨进程往返;
  • correlation timeout/cleanup 分支多;
  • stdout 污染、child crash、版本不匹配都是新故障面;
  • tool set 变化可能重建 Pi session;
  • parent 与 subprocess 类型定义若不共享生成物,可能漂移。

当第三方 Agent SDK 与宿主 bundler/运行时冲突时,独立进程是很实用的 anti-corruption layer。关键不是“用 JSONL”本身,而是坚持:

  1. stdout 只承载协议;
  2. request 全有 correlation 与 timeout;
  3. parent 保留安全与秘密所有权;
  4. terminal path 排空 pending;
  5. turn event 用 queue 桥接到 AsyncGenerator;
  6. tool/config 变更只在安全边界生效;
  7. provider-native session anchor 被显式持久化。

PiAgent 是一个完整的进程边界适配器:不是把 prompt 发给 CLI,而是在两个进程之间重建了 agent loop 所需的事件、工具、权限、模型配置和生命周期协议。

下一章把 Claude/Pi 的差异再次折叠起来,看统一 AgentEvent 如何变成持久消息、WebSocket push 和 renderer 的一致状态。