开源编程 Agent pi 的服务端包:进程怎么拉起、怎么看住

2026-07-29

本文基于 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.tsRpcProcessInstance 构造函数里,用的是 Node 的 spawnstdio 三路全 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 rpcmain。把警告输出摁掉这一手,对一个拿标准输出当协议管道的进程来说是必要的——通道里混进一行不是 JSON 的东西,整条链路就得出事,这一点后面还会提到。

拉起之后,ServerSupervisor.spawnInstance 的顺序是固定的:先生成一条 InstanceRecordidrandomUUID,状态 starting)并立刻落盘,再创建子进程、绑定监听、发一次 get_statesessionIdsessionFile 同步回记录,之后向 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 认领,不靠先后顺序。

接收端 handleLinetype 三分:responsependingRequests 里找主人并 resolve;extension_ui_request 交给唯一的 uiRequestHandler;剩下的一律当作会话事件,广播给 eventListeners 里的每一个订阅者。这三类的处理方式差别很大,值得单独记一下:响应是一对一的,UI 请求是一对一但处理器可被覆盖,事件是一对多的广播。

supervisor.ts 里的 openRpcStream 就建立在这套广播机制上(handler.ts 在它外面又包了一层,把命令和 UI 应答合并成一个 handleRequest 入口对外)。它往 live.subscribers 里加一个监听函数,返回 handleRpchandleUiResponseclose 三个方法;close 只删自己那个监听器,并且只有在 live.onUiRequest 确实还是自己注册的那个时才清空——这处判断避免了后开的连接被先关的连接误伤。

不过要注意,允许并发不等于实际并发。packages/server/src/ipc/server.ts 处理 rpc_stream 长连接时,用了一个 rpcRequestQueue 把同一条连接上进来的命令串成链式 Promise 依次执行。所以:传输层支持乱序应答,但单条流式连接上的命令是串行的。这两层的差异如果没看代码,很容易凭直觉猜错。

四、“看住”具体看的是什么

“看住”在这个包里不是重启策略,而是一套状态记账。packages/server/src/types.ts 里的 InstanceStatus 只有五个值:startingonlinestoppingstoppederror。所有状态迁移都走 setStatusupdateRecord,这两个方法每次都会刷新 lastSeenAt 并调 upsertInstance 落盘。

几处具体设计值得看:

异常退出的处理。 bindRpcProcess 里注册了 onExit,一旦子进程意外结束就进 handleUnexpectedRpcExit。它先做两道守卫:如果内存表里这个 id 对应的对象已经不是当前这个了就直接返回,如果状态已经是 stoppingstopped 说明是主动停的、也直接返回。剩下的才是真意外:置 error、解绑监听、断开 Radius 在线态、从内存表删掉。而在传输层那边,exiterror 事件都会调 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()。这个方法的实际行为是:把持久化清单里状态为 onlinestarting 的记录统统改成 stopped,逐条断开 Radius 在线态,然后整体存回去。它不会去重新拉起任何子进程。 这是个有意的选择:守护进程死了,它拉起的子进程也就没了父子关系上的托管者,与其猜哪些还能救,不如老老实实把账平掉。

退出路径。 serve.ts 用一个 shutdownPromise 变量做幂等——第二次触发关闭时直接 await 第一次的 Promise 再退出。关闭流程是关 server、supervisor.shutdown() 挨个停实例、停 Radius、删掉 socket 文件。信号方面 SIGINTSIGTERM 都以退出码 0 走这条路,而 uncaughtExceptionunhandledRejection 会先打日志再以退出码 1 走同一条路。最后用一个永不 resolve 的 Promise 把进程挂住,代码里那行注释的意思就是:保持进程存活,直到信号或致命错误触发关闭。

五、这个包由哪些块拼起来

组成部分它负责什么仓库位置你什么时候会碰到它
cli.ts提供 server 命令的各个子命令,并把 rpc-stream 的 stdin 转成 socket 上的 JSONLpackages/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.jsonauth.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,连不上(ECONNREFUSEDENOENTEPIPEECONNRESET 这几类)就当成陈旧文件删掉重建。为什么会踩:进程被强杀后 socket 文件常常还在,看到文件就手动删是很多人的第一反应。怎么避:不要手删,先跑一次 serve 看它报什么——报”已在运行”说明真有活的进程,先找到它。

不要用工作目录之外的方式隔离实例。 spawncwd 是必填的,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.tsipc/server.tshandler.tssupervisor.tsrpc-process.ts 的顺序走一遍,一条请求从 socket 到子进程 stdin 的完整路径就串起来了。

自己的项目要做类似的东西,可以对着下面四个问题自查:实例状态一共有几种、每种由谁负责改;子进程崩了之后,那些还在等回复的调用方多久能收到明确的失败;守护进程重启后,你是选择复活还是平账,理由是什么;协议通道和日志通道有没有物理隔开。这四个问题答不上来的话,先别急着写代码。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的可持久化运行给开源编程 Agent pi 接入自定义模型服务

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。