`pi-server` 管的是进程,还是 Agent
从 server CLI、本地 IPC、ServerSupervisor 与 RPC 子进程的调用关系,判断实例元数据、进程生命周期和 Agent 运行状态分别归谁所有。
第 40 章把 RPC 模式拆成了一个可收命令、可发事件的长驻进程。仓库里的 packages/server 又在它外面加了一层:启动多个 RPC 子进程,给它们分配实例 id,再通过本地 IPC 提供 spawn、status、stop、rpc 和 rpc-stream。这里最容易写错的一句话是“pi-server 管理多个 Agent”。
它确实能间接控制多场 Agent 会话,但直接所有的是进程句柄、实例元数据和桥接关系。AgentSession、底层 Agent、消息队列与工具执行仍由各个 RPC 子进程持有。这个区别决定了 server 崩溃后还能恢复什么,也决定了 instances.json 能不能作为运行事实。
先把名称说准。npm 包叫 @earendil-works/pi-server,实际注册的可执行命令是 server。固定版本的 README 还把整个包标为 experimental,明确说 CLI、API 与行为可能变化或移除。因此本章是在解释 v0.83.0 的源码形态,不把它描述成已经稳定的远程 Agent 平台。
spawn 之后真正多了哪个进程
server serve 先在配置目录下创建本地 socket,挂上 IPC handler,然后调用 recoverAfterRestart()。其他 CLI 子命令本身不持有 Agent:server spawn、server list、server rpc 等只是连接这条 socket,发送一条请求并打印响应;rpc-stream 才会保持连接,把 stdin JSONL 与 socket 双向转发。
ServerSupervisor.spawnInstance() 收到 spawn 后,先生成 UUID,把一条 starting 记录写进内存 map 和 instances.json,再创建 RpcProcessInstance。后者调用 Node spawn(),工作目录使用实例 cwd,stdio 全部设为 pipe;Node 分发下运行 coding-agent 的 rpc-entry,编译成 Bun binary 时则执行相邻的 pi --mode rpc。所以每个 server instance 对应的是一个独立的 RPC 子进程,不是 supervisor 内的一份 Agent 对象。
从所有权看,层次是这样的:
flowchart LR
accTitle: pi-server 直接管理的是 RPC 子进程
accDescr: server CLI 通过本地 IPC 请求 supervisor;supervisor 持有实例记录和子进程句柄,命令进入子进程后才由 AgentSession 与 Agent 执行。
CLI["server CLI"] --> IPC["local IPC socket"]
IPC --> SUP["ServerSupervisor"]
SUP --> META["instances.json metadata"]
SUP --> CHILD["RpcProcessInstance / child process"]
CHILD --> RPC["coding-agent RPC mode"]
RPC --> SESSION["AgentSession"]
SESSION --> AGENT["Agent + model + tools"]
AGENT --> SESSION
SESSION --> RPC
RPC --> CHILD
CHILD --> SUP
SUP --> IPC
这张图也解释了为什么 supervisor 没有 prompt()、abort() 或 setModel() 这样的 Agent 方法。它只把 RpcCommand 写入子进程 stdin,按 request id 保存 pending promise,再从 stdout JSONL 中分流 response、extension UI request 与普通 session event。真正理解命令并修改会话的仍是第 40 章里的 RPC mode。
桥接事件,不复制 Agent 状态
bindRpcProcess() 给子进程绑定三类出口:AgentSession event 广播给当前 subscribers,进程退出进入异常处理,extension UI request 转交给当前 stream handler。openRpcStream() 则把一条外部 socket 流接到这些出口;收到 RPC command 时,它调用子进程 send(),必要时再补一次 get_state 来刷新记录。
需要刷新的命令只有 new_session、switch_session、fork、clone、set_session_name 和 prompt。记录里也只有 id、status、cwd、时间、label、sessionId、sessionFile 等摘要字段。模型流中的完整消息、steering/follow-up queue、当前 tool call 和 abort signal 都没有复制进 supervisor。
这意味着 event bridge 是观察与控制通道,不是第二份 Agent 状态机。某个事件已经从子进程发出,能证明它抵达过 supervisor;它不能证明 server 有能力在子进程消失后从该事件继续执行。instances.json 同理,它通过普通 JSON 文件整表读写保存摘要,可供 list/status 使用,却没有 event cursor、未完成工具的 durable receipt 或可重建 pending request。
异常退出、重启与正常关闭留下不同结果
RPC 子进程发生 error 或 exit 时,RpcProcessInstance 会拒绝所有 pending request,并通知 exit listeners。supervisor 若发现实例不是主动 stopping/stopped,就先把持久状态改为 error,清掉绑定和进程引用,再从 live map 删除。记录仍在 instances.json,因此之后的 list/status 可以看到 error 摘要,但 RPC 已经不能再发给它。
server 自身重新启动时也不会扫描记录并重新 spawn。recoverAfterRestart() 只把旧的 online 和 starting 改成 stopped,更新时间后重新保存。它没有恢复子进程,更没有恢复中断在模型流或工具调用中的 Agent run。
主动 stop 走另一条路径:状态先变为 stopping,清理绑定并 dispose RPC 子进程,最后从 live map 和持久实例列表删除。server 收到 SIGINT、SIGTERM 或 fatal error 时会调用 supervisor.shutdown(),逐个执行相同的 stop。这里的 shutdown 是进程清理合同,不是“保留所有在线实例等待下次恢复”。
不启动服务,也能复核所有权
下面的实验只读取固定 tag。它不会创建 socket、写入用户的 ~/.pi/server,也不会留下 RPC 子进程:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/server/package.json |
nl -ba | sed -n '1,16p'
git -C "$repo" show v0.83.0:packages/server/src/rpc-process.ts |
nl -ba | sed -n '25,60p;86,98p'
git -C "$repo" show v0.83.0:packages/server/src/supervisor.ts |
nl -ba | sed -n '99,134p;244,255p;270,339p'
复核时只问三个问题:谁持有 ChildProcess,谁解析 RpcCommand,谁在重启后重建 active run。固定版本的答案分别是 RpcProcessInstance、子进程中的 RPC/AgentSession、没有任何一层。于是标题也有了准确回答:pi-server 是 RPC 进程与实例摘要的 supervisor,Agent 只是它通过协议控制的子进程内部状态。
这个结论留下一个新的验证难题。进程能被启动、命令能被转发,不等于 Agent 一定完成了预期行为。第 42 章转向仓库里的 Evals,看 Pi 如何把真实模型输出、工具调用、会话状态与用量整理成可断言的结果,也看清这种证据仍然会受模型和凭据影响。