开源编程 Agent pi 的服务端包:进程怎么拉起、怎么看住
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
把一个 agent 从”你在终端里跟它聊”变成”别的程序可以调度它”,真正麻烦的不是通信协议,而是进程生命周期的记账:谁拉起来的、现在算活着还是算死了、崩了之后那些还在等回复的请求怎么办、守护进程自己重启了又该认为哪些实例还在。pi 把这一摊事单独放进了一个包里,包名 @earendil-works/pi-server,代码在仓库的 packages/server/。这个包的 README 第一句就写着 Experimental,说它仍在开发中、CLI 与 API 都还不稳定,甚至可能被移除。所以下面讲的不是”最佳实践模板”,而是一份可以当场打开核对的真实取舍样本。
pi 是 MIT 许可证的开源项目,主仓库在 https://github.com/earendil-works/pi ,截至 2026-07 在 GitHub 上约有 8 万 star。它的核心 agent 逻辑在 packages/coding-agent/,而服务端包只依赖它、不改它。
一、这个包要解决的问题:一个 agent 进程不够用之后
先说清边界。单人单仓库的日常使用里,你根本不需要这个包——起一个终端,跑 agent,聊完退出,进程和会话是一一对应的。
问题出在你想同时开几个的时候。不同工作目录、不同任务、互不干扰,还得有个地方知道”现在总共有哪几个在跑”。这时候你需要三样东西:一个能被外部调用的入口、一份实例清单、一套把请求路由到正确进程的机制。
pi 的做法是把这三样都塞进服务端包,对外暴露一个叫 server 的命令行。它的帮助文本(packages/server/src/cli.ts 里的 printHelp)列得很直白:
Usage:
server serve
server list
server spawn [--cwd <path>] [--label <label>]
server status <instance-id>
server stop <instance-id>
server rpc <instance-id> <json-command>
server rpc-stream <instance-id>
server --help
server --version
帮助文本末尾还补了一句:rpc-stream 的 stdin 只接受 JSONL 形式的 RpcCommand 或 extension_ui_response 消息。
serve 起守护进程,其余子命令都是客户端:连到同一个本地 socket,发一行 JSON 请求,读一行 JSON 响应。守护进程内部由 ServerSupervisor 这个类统管所有实例,它在 packages/server/src/supervisor.ts 末尾以单例形式导出。
顺带说清本篇与站内两类文章的分工。讲多个 agent 怎么切分任务、怎么并行不打架,那是编排层的方法论,见并发编排;讲后台跑的 agent 明明失败了却没人发现,那是失败判定的方法论,见静默失败;想在事后翻记录搞清它当时为什么那么做,见可观察日志。本篇不谈方法论,只看一个具体项目把这些事落到了哪几行代码上——它的状态机长什么样、崩溃时具体做了哪几步清理。
二、进程是怎么被拉起来的
拉起动作在 packages/server/src/rpc-process.ts 的 RpcProcessInstance 构造函数里,用的是 Node 的 spawn,stdio 三路全 pipe,cwd 来自调用方,env 直接透传 process.env。
有意思的是拼命令行的那段,它分了两条路:
private getSpawnCommand(): { command: string; args: string[] } {
if (isBunBinary) {
return {
command: join(dirname(process.execPath), process.platform === "win32" ? "pi.exe" : "pi"),
args: ["--mode", "rpc"],
};
}
return {
command: process.execPath,
args: [require.resolve("@earendil-works/pi-coding-agent/rpc-entry")],
};
}
isBunBinary 定义在 packages/server/src/config.ts,判断依据是 import.meta.url 里是否含有 $bunfs、~BUN 或 %7EBUN 这类 Bun 虚拟文件系统路径的痕迹。也就是说:如果服务端自己是被打包成 Bun 单文件二进制在跑,它就去同目录找 pi(Windows 上是 pi.exe)并带 --mode rpc 启动;否则就用当前 Node 可执行文件去跑 @earendil-works/pi-coding-agent/rpc-entry 这个导出入口。
这个 --mode 是 agent 本体的参数,packages/coding-agent/src/cli/args.ts 的帮助文本里写着 Output mode: text (default), json, or rpc。而 rpc-entry.ts 干的事非常克制:设置 process.title、把 process.env.PI_CODING_AGENT 置为 "true"、把 process.emitWarning 替换成空函数,然后带上 --mode rpc 调 main。把警告输出摁掉这一手,对一个拿标准输出当协议管道的进程来说是必要的——通道里混进一行不是 JSON 的东西,整条链路就得出事,这一点后面还会提到。
拉起之后,ServerSupervisor.spawnInstance 的顺序是固定的:先生成一条 InstanceRecord(id 用 randomUUID,状态 starting)并立刻落盘,再创建子进程、绑定监听、发一次 get_state 把 sessionId 和 sessionFile 同步回记录,之后向 Radius 注册在线状态,最后才把状态改成 online。任何一步抛错都掉进 failSpawn:先置 error,清理已经拿到的资源,finally 里置 stopped 并从内存表里删掉,然后把原始错误重新抛出去。
三、请求是怎么送进去、结果又是怎么回来的
子进程的协议是 JSONL:一行一个 JSON 对象。写请求是往 stdin 写,读结果是从 stdout 按 \n 切行。
发送端的关键在于请求 ID:
const id = command.id ?? `server_${++this.nextRequestId}_${randomUUID()}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.process.stdin?.write(`${JSON.stringify(fullCommand)}\n`, ...);
});
一个自增序号加一个 UUID,然后把 resolve/reject 存进 pendingRequests 这张表。这意味着传输层本身是允许多条请求同时在飞的——回来的响应靠 id 认领,不靠先后顺序。
接收端 handleLine 按 type 三分:response 去 pendingRequests 里找主人并 resolve;extension_ui_request 交给唯一的 uiRequestHandler;剩下的一律当作会话事件,广播给 eventListeners 里的每一个订阅者。这三类的处理方式差别很大,值得单独记一下:响应是一对一的,UI 请求是一对一但处理器可被覆盖,事件是一对多的广播。
supervisor.ts 里的 openRpcStream 就建立在这套广播机制上(handler.ts 在它外面又包了一层,把命令和 UI 应答合并成一个 handleRequest 入口对外)。它往 live.subscribers 里加一个监听函数,返回 handleRpc、handleUiResponse、close 三个方法;close 只删自己那个监听器,并且只有在 live.onUiRequest 确实还是自己注册的那个时才清空——这处判断避免了后开的连接被先关的连接误伤。
不过要注意,允许并发不等于实际并发。packages/server/src/ipc/server.ts 处理 rpc_stream 长连接时,用了一个 rpcRequestQueue 把同一条连接上进来的命令串成链式 Promise 依次执行。所以:传输层支持乱序应答,但单条流式连接上的命令是串行的。这两层的差异如果没看代码,很容易凭直觉猜错。
四、“看住”具体看的是什么
“看住”在这个包里不是重启策略,而是一套状态记账。packages/server/src/types.ts 里的 InstanceStatus 只有五个值:starting、online、stopping、stopped、error。所有状态迁移都走 setStatus 或 updateRecord,这两个方法每次都会刷新 lastSeenAt 并调 upsertInstance 落盘。
几处具体设计值得看:
异常退出的处理。 bindRpcProcess 里注册了 onExit,一旦子进程意外结束就进 handleUnexpectedRpcExit。它先做两道守卫:如果内存表里这个 id 对应的对象已经不是当前这个了就直接返回,如果状态已经是 stopping 或 stopped 说明是主动停的、也直接返回。剩下的才是真意外:置 error、解绑监听、断开 Radius 在线态、从内存表删掉。而在传输层那边,exit 和 error 事件都会调 rejectAllPending,把所有还在等的请求一次性拒绝掉,错误消息里还会带上累积的 stderr 内容。这一点很实用——agent 崩了的时候,真正有信息量的往往就是它临死前写到 stderr 的那几行。
元数据的按需刷新。 每执行完一条命令就回头查一次会话状态,是最省事但也最浪费的写法。supervisor 里维护了一个白名单:
const SESSION_METADATA_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set([
"new_session",
"switch_session",
"fork",
"clone",
"set_session_name",
"prompt",
]);
只有这几类命令执行后才触发 syncInstanceRecord 去发 get_state。代码上方的注释把理由写得很清楚:多数 RPC 只改运行时的临时状态,硬要每条命令后面都跟一次 get_state,那是白白浪费 IO。
守护进程自己重启之后。 serve() 启动流程里,第一件事是建 socket 目录并 startIpcServer,紧接着调 supervisor.recoverAfterRestart()。这个方法的实际行为是:把持久化清单里状态为 online 或 starting 的记录统统改成 stopped,逐条断开 Radius 在线态,然后整体存回去。它不会去重新拉起任何子进程。 这是个有意的选择:守护进程死了,它拉起的子进程也就没了父子关系上的托管者,与其猜哪些还能救,不如老老实实把账平掉。
退出路径。 serve.ts 用一个 shutdownPromise 变量做幂等——第二次触发关闭时直接 await 第一次的 Promise 再退出。关闭流程是关 server、supervisor.shutdown() 挨个停实例、停 Radius、删掉 socket 文件。信号方面 SIGINT 和 SIGTERM 都以退出码 0 走这条路,而 uncaughtException 和 unhandledRejection 会先打日志再以退出码 1 走同一条路。最后用一个永不 resolve 的 Promise 把进程挂住,代码里那行注释的意思就是:保持进程存活,直到信号或致命错误触发关闭。
五、这个包由哪些块拼起来
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
cli.ts | 提供 server 命令的各个子命令,并把 rpc-stream 的 stdin 转成 socket 上的 JSONL | packages/server/src/cli.ts | 手工调试、写脚本调用时 |
serve.ts | 守护进程的启动与关闭编排、信号处理、socket 文件清理 | packages/server/src/serve.ts | 排查”起不来”或”关不干净” |
ipc/server.ts | 监听本地 socket、解析请求行、处理 rpc_stream 长连接与串行队列 | packages/server/src/ipc/server.ts | 接入自己的客户端时 |
handler.ts | 把 IPC 请求分派给 supervisor,并把内部记录裁剪成对外的摘要结构 | packages/server/src/handler.ts | 想知道对外字段有哪些 |
supervisor.ts | 实例状态机、事件订阅广播、异常退出处理、重启后恢复 | packages/server/src/supervisor.ts | 状态显示不对时 |
rpc-process.ts | 拉起 agent 子进程、JSONL 收发、请求 ID 配对、销毁 | packages/server/src/rpc-process.ts | 子进程行为异常时 |
storage.ts + config.ts | 清单文件与目录路径的读写解析 | packages/server/src/storage.ts | 想知道文件落在哪 |
路径解析这块单独说一句,config.ts 里的规则是:环境变量 PI_SERVER_DIR 优先,其次是 PI_CONFIG_DIR 或用户主目录下的 .pi,再拼上 server 子目录。socket 文件叫 server.sock,实例清单叫 instances.json,另外还有 machine.json 和 auth.json。
六、边界与代价:它明确不管的那些事
这个包的取舍相当克制,代价也很明确。
不做自动重启。 前面说过,recoverAfterRestart 只平账不复活。子进程意外退出时,handleUnexpectedRpcExit 也只是标记 error 然后放手。想要”挂了自己爬起来”,得你自己在外面套一层。
不做鉴权和网络暴露。 通道是本地 socket 文件,谁能读写这个文件谁就能操作所有实例,代码里没有任何调用方身份校验。想跨机器用,安全那一层得自己补。
记录清理不对称。 stopInstance 走的是 removeInstance,会把这条记录从清单里删掉;而 failSpawn 最后走的是 setStatus(stopped),靠 upsertInstance 落盘,记录会留下。意外退出留下的则是 error 状态的记录。所以 instances.json 会随时间积累一些非 online 的条目,这不是 bug,只是它没打算替你做垃圾回收。
扩展 UI 请求只认一个处理器。 live.onUiRequest 是单个字段而非集合,后注册的会覆盖先注册的。会话事件是广播、UI 请求不是,因为后者需要一个明确的应答方。
它不管模型这一层。 账号、密钥、网络可达性、模型选择,全在 agent 本体那边。这里额外提醒一句:海外模型服务商官方对中国大陆地区存在区域限制、不支持直连,市面上确实存在第三方中转,但可靠性与合规性需要你自己评估,这里不做任何推荐;各家的规则不同且会调整,以官方最新说明为准。
协议解析没有容错兜底。 handleLine 里的 JSON.parse 没有包 try/catch。子进程的 stdout 一旦混进非 JSON 的行——比如某个依赖顺手打了条日志——解析就会抛,而 serve.ts 顶层的 uncaughtException 处理会把整个守护进程带走。这解释了为什么 rpc-entry.ts 要那么小心地压制警告输出:stdout 是协议通道,不是日志通道。
七、上手与避坑清单
先把”守护进程已在运行”这条错认清。 startIpcServer 里的 removeStaleSocketIfNeeded 会先看 socket 文件在不在,在的话主动连一下探活:连上了就抛 server is already running,连不上(ECONNREFUSED、ENOENT、EPIPE、ECONNRESET 这几类)就当成陈旧文件删掉重建。为什么会踩:进程被强杀后 socket 文件常常还在,看到文件就手动删是很多人的第一反应。怎么避:不要手删,先跑一次 serve 看它报什么——报”已在运行”说明真有活的进程,先找到它。
不要用工作目录之外的方式隔离实例。 spawn 时 cwd 是必填的,env 则是整个透传父进程的。为什么会踩:以为不同实例之间环境变量也是隔离的,于是想靠环境变量给不同实例配不同参数。怎么避:把隔离建立在工作目录上,环境变量的差异要在起守护进程时就定好。关于工作目录隔离的一般原则,工作区隔离那篇讲得更细。
别让子进程往 stdout 写非协议内容。 为什么会踩:接入自定义扩展或包装脚本时,习惯性加几行 console.log 调试。怎么避:调试信息一律走 stderr——传输层本来就在 stderrBuffer 里累积它,进程出问题时还会把它拼进错误消息,比 stdout 有用得多。
状态字段不等于健康检查。 lastSeenAt 是每次记录更新时刷的时间戳,不是心跳探测的结果。为什么会踩:看到 online 加一个新鲜的时间戳,就当作实例一定还能干活。怎么避:真要确认可用,发一条命令看有没有回应;空闲很久的实例,lastSeenAt 本来就不会自己往前走。
send 在进程已退出时是直接抛,不是返回被拒绝的 Promise。 为什么会踩:只挂了 .catch() 而没有 try/catch 的同步调用点会被穿透。怎么避:调用点放在 async 函数里(包内的 handleRpc 就是这么写的),同步抛出会自然变成 rejection。
接入前先确认运行环境。 这个包的 package.json 里带了 engines.node 字段,对 Node 版本设了下限,具体门槛以仓库里的该字段为准。为什么会踩:CI 或服务器上的 Node 版本往往比本机旧。怎么避:先对版本,再谈别的。日常怎么把这类环境前提固定下来,日常运维那篇有更成体系的说法。
最后一条:认清 Experimental 的分量。 README 明说了 CLI、API 和行为都不稳定,甚至可能被移除。怎么避:把它当作值得研读的结构参考,而不是可以直接依赖的稳定接口;真要接,锁死版本号,并且在自己这边留一层适配。
收个尾
如果你只想拿一条结论走:这个包把”传输”和”记账”分得很干净——rpc-process.ts 只管一个子进程的字节进出,supervisor.ts 只管所有实例的状态账本,两者靠事件回调连接。想读代码,按 serve.ts → ipc/server.ts → handler.ts → supervisor.ts → rpc-process.ts 的顺序走一遍,一条请求从 socket 到子进程 stdin 的完整路径就串起来了。
自己的项目要做类似的东西,可以对着下面四个问题自查:实例状态一共有几种、每种由谁负责改;子进程崩了之后,那些还在等回复的调用方多久能收到明确的失败;守护进程重启后,你是选择复活还是平账,理由是什么;协议通道和日志通道有没有物理隔开。这四个问题答不上来的话,先别急着写代码。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的可持久化运行 和 给开源编程 Agent pi 接入自定义模型服务。