Pi 源码拆解(四):实验性客户端/服务端会话拆分

2 分钟阅读
·

本文是「Pi 源码拆解」系列第 4 篇。系列目录:

第 1 篇讲分层时提到,仓库中还有 protocol、server、client、storage 一组包,均标记 experimental,本文进一步做源码分析。

当前代码处于过渡状态。server 包 README 第一行写明 “Experimental… may change or be removed without notice”;服务端核心接口 PiSessionBackend 全仓库仅有测试实现,无生产消费者;client 侧 RemoteSession 已合入 coding-agent 但无文档、未默认启用;旧的 JSONL IPC 和子进程 supervisor 仍保留在 server 包的 legacy/ 子目录。下图展示新增会话服务与旧路径并存的位置。下文分析这套过渡实现如何通过 repository、协议和测试接口定义已拆出的状态语义。

Pi 实验性客户端/服务端会话架构:客户端经协议包访问 PiServer,PiServer 通过 backend 打开持久化会话;legacy supervisor 和 RPC 子进程路径仍被保留

旧模型:一个前端独占一个子进程

拆分前,pi 的程序化接口是 RPC 模式:pi --mode rpc 启动一个无头 agent 进程,命令从 stdin 进入、事件从 stdout 输出,全部采用 JSONL 分帧(packages/coding-agent/docs/rpc.md,文档专门指出 Node 的 readline 会把 U+2028 当作换行,不符合协议的严格 LF 分帧)。每个前端要一个会话就 spawn 一个子进程;两个前端即两个子进程、两份互不相见的会话。会话文件本身持久化在磁盘,pi -c/-r 可以恢复,但活跃会话同一时刻只属于一个进程——想从 TUI 切到另一个前端查看同一会话,只能退出重开。

server 包的 legacy/ 目录保留了基于该模型的 supervisor:它接受 spawn/list/stop/status/rpc 请求(legacy/ipc/protocol.ts 中的 RequestMap),为每个 spawn 创建一个 pi --mode rpc 子进程(packages/server/src/legacy/rpc-process.ts:50-59,spawn 参数即 ["--mode", "rpc"]),supervisor 与子进程之间同样走 JSONL 文本行。supervisor 解决的是批量管理子进程,但一前端对一进程的一对一形态没有改变。

拆分前后对比:一个前端独占一个子进程 vs server 长驻多前端 attach

会话存储接口

拆分进程前,需要先明确会话状态由谁管理。旧代码中会话读写散落在 harness 各处;若直接引入进程边界,状态所有权、持久化和一致性问题都会进入跨进程协议。git log 显示,存储改造早于 protocol/client/server 三个包:先隔离 Node 文件系统依赖,随后加入 SQLite 后端。一周内实现 protocol/client/server 后,提交 “clarify session persistence ownership” 和 “compose session storage through repositories” 又继续调整存储职责。这些接口仍在演进。

SessionRepository 定义了五个会话操作(packages/agent/src/harness/session/repository.ts:22-32):create、open、list、delete、fork。jsonl-repo 沿用会话文件的 JSONL 树格式;packages/storage/sqlite-node 使用 SQLite,开启 WAL(sqlite-node/src/sqlite/repo.ts:39),并以独立的 FTS5 虚拟表处理搜索(sqlite-node/src/sqlite/search-backend.ts:41),只对搜索提供只读投影。另有 memory-repo 用于测试和嵌入场景。harness 通过该接口访问会话,不直接处理文件路径或数据库连接。提交 “reject unsupported session search” 规定:后端不支持搜索时直接报错,不退回全表扫描。接口还包含 fork 操作。createSessionForkSelectionrepository.ts:51-56)将”从某条 entry 之前或之处分叉”转换为 entry 序列拷贝,分叉不再依赖旧进程内存中的树。这些接口和后端定义了跨进程共享会话所需的存储行为,但生产 backend 尚未接入。

协议与传输边界

protocol 包是拆分的地基,刻意不绑定任何传输。README 写明 “This package does not bundle a transport”,进出都是字节流,分层为 bytes → frames → CBOR → TypeBox 校验后的消息。逐层看:

framing 采用 4 字节无符号大端长度前缀(packages/protocol/src/framing.ts:27-28),默认单帧上限 16 MiB(framing.ts:6)。FrameDecoder 是增量式的,接受任意切分与合并的字节块,因此流、socket、自定义传输均可使用。

CBOR 是手写的 RFC 8949 严格子集,不依赖现成库。子集只收 null 与布尔、有限数(整数限在 JavaScript 安全范围,非整数一律编码为 float64)、UTF-8 字符串、字节串、定长数组和 map;tag、不定长项、BigInt 均不支持,编码面因此没有歧义。选 CBOR 而非 JSON 的理由是:自描述的确定性二进制格式,与长度前缀帧配合时无需处理转义与空白。解码器的防御性上限是另一半理由:payload 16 MiB、容器 100 万元素、嵌套 64 层(cbor/options.ts:6-8),超限直接抛 CborError。协议面处理不受信输入,解码器需要拒绝恶意构造的深嵌套和巨型容器。手写实现使项目能明确限制可接受的 CBOR 子集、容器规模和嵌套深度。

消息层全部用 TypeBox 描述,StrictObject 一律 additionalProperties: falseschemas.ts:7-8),未知字段直接拒绝。PROTOCOL_VERSION 当前为 2(schemas.ts:3)。

会话面只有 9 个命令:list、create、attach、detach、prompt、steer、abort、set_model、set_thinking(schemas.ts:287-320)。错误码 6 个:auth、version、busy、session_locked、not_found、invalid_request(schemas.ts:266-273)。命令面小是刻意设计:工具执行、compaction、扩展事件这些复杂行为不穿过协议,线上只传输 transcript 和少量控制指令。

prompt 和 steer 的区分直接沿用第 2 篇的运行时词汇:prompt 是开启新一轮的用户输入,steer 是运行中的 steering 注入,在 turn 边界生效,不打断当前流。协议层不解释这些语义,只负责送达。下行方向的事件有 4 种:server_snapshot、session_snapshot、session_progress、session_removed(schemas.ts:397-406)。ServerSnapshot 中除会话列表外还携带 models 清单(schemas.ts:257-264),前端连接后即知可用模型、各自的认证状态和计价。握手响应发出前 server 还执行一次 revision 检查:取快照期间若有新变更则补发最新版本(server.ts:247-253),保证客户端从握手完成起不落后于权威状态。

连接从 hello 握手开始:客户端第一帧必须是 hello,携带整数版本号和 bearer token(schemas.ts:380-386)。server 侧对 token 做 SHA-256 摘要后用 timingSafeEqual 比对(packages/server/src/server.ts:257-259),版本不符报 version 错误,握手默认 5 秒超时(server.ts:30)。连接的握手路径为 awaitingHello → handshaking → ready 三段(server.ts:131-140, 186-220),握手完成前到达的请求挂在握手 promise 之后等待。

传输是注入的。server 唯一内建的传输是 unix socket(transports/unix/),client 侧对应 unix.ts;ByteTransport 接口只有 send 和 close 两个方法(packages/client/src/transport.ts:1-6)。server 包还导出 ./testing 子路径,内含 WireChannel 抽象和一套传输一致性测试,第三方实现新传输可运行同一套测试验证行为一致。protocol 包本身不 import 任何 node: 模块,与第 1 篇所述的 agent 核心采用同一手法。

客户端以会话快照更新状态

多个前端连接同一会话时,客户端需要知道哪些消息可用于更新本地状态。pi 将完整 SessionSnapshot 作为状态来源。server 每次操作完成后广播完整快照,携带单调递增的 revision;大多数命令的响应也直接携带新快照(schemas.ts:324-351)。TranscriptProgress 仅报告当前生成过程中的增量活动,schema 注释写明 “Normalized incremental activity. Snapshots remain authoritative.“(schemas.ts:203),protocol README 也说明 progress 是 transient UI hints,“must not be reduced into authoritative state”。

client 包 README 规定,客户端用快照和成功响应更新状态,不将 progress 合并为会话数据。coding-agent 中的 RemoteSession 将远端会话表示为本地 transcript,progress 存在独立的 progressItems 中,读取时才叠加到快照 transcript 上(packages/coding-agent/src/client/transcript.ts:78):收到新快照后整体替换 transcript,progress 只影响渲染,不写回会话状态。

快照中有一个细节值得注意:queuedSteer 和 queuedSteerCount(schemas.ts:251-252)。已排队但尚未到达 turn 边界的 steering 消息也包含在快照中,中途 attach 进来的新前端能看到”已有插话排队、尚未生效”的状态。

该方案不实现自动重连、事件重放或对账协议。PiClient 不自动重连(README 原话 “PiClient does not reconnect automatically”),由调用方决定何时 reconnect;重连后 hello 响应携带全量 ServerSnapshot,再 attach 取得会话快照并重新对齐。代价是闪断期间的 progress 不会恢复,但权威状态不依赖这些 UI 增量。

客户端如何限制同一会话的并发操作

多个前端可以连接同一会话,但不能同时发起相互冲突的操作。acquireSession(sessionId, { mode }) 返回 SessionLease 对象,用它记录客户端对会话的使用权限。mode 分为 exclusive 和 shared:createSession 默认取得 exclusive(packages/client/src/client.ts:144),attachSession 对应 shared(client.ts:148-150)。client 在本地按会话记录 SessionLease 数量:exclusive 不能与任何已有 SessionLease 共存,shared 不能与 exclusive 共存;发生冲突时直接抛 PiSessionOwnershipError(client.ts:382-394),无需等待网络响应。最后一个 SessionLease 释放时,client 自动向 server 发送 detach。

server 侧的约束写在接口注释中:PiSessionRuntime 的 doc comment 为 “Conflicting operations must reject rather than queue.“(packages/server/src/types.ts:42)。runtime 忙时第二个写操作收到 busy 错误,不排队。LiveSessionManager 维护 attach 关系:每个会话命令先经过 requireAttached 检查(sessions.ts:316-325),attach 将连接加入会话的 connections 集合(sessions.ts:307-314),活跃会话对外的快照一律标记 locked: true(sessions.ts:292)。连接断开时的清理由 disconnect 统一处理:将该连接从它挂载过的所有会话中移除,逐个触发回收检查(sessions.ts:127-137),不需要客户端在断开前发送任何消息。

SessionLease 是一个轻量对象,并实现 AsyncDisposable(packages/client/src/session-handle.ts:84-86)。通过 await using 声明时,它会在作用域结束后自动发送 detach,避免客户端遗留未释放的会话连接。

会话本身比连接长寿。attach 时如果 runtime 不在内存,manager 通过 backend.openSession 从存储复活(sessions.ts:71-79);所有连接断开、没有进行中的操作且 phase 回到 idle 时,runtime 被 dispose 释放(sessions.ts:331-352),会话数据留在存储中。因此”会话长驻”的准确表述是:server 进程长驻、存储长驻,runtime 按需复活和回收。

适配层与双栈并存

server 包中一个关键的文件是 protocol.ts,即 pi-ai 消息模型与协议 DTO 之间的适配层。文件开头是一排编译期断言:ExactKeys 逐个枚举 pi-ai 类型中被映射和被有意丢弃的字段,注释写明 “additions fail compilation here”(packages/server/src/protocol.ts:25-36)。上游 pi-ai 给 AssistantMessage 加字段时此处编译即失败,迫使维护者当场决定新字段是否上协议。丢弃同样是有意为之:thinkingSignature、diagnostics、cache 计价细分这些留在 server 侧,不上线缆传输。

legacy/ 子目录仍保留 JSONL IPC、supervisor 和 server CLI。README 说明新 API “is additive while the legacy child-process supervisor and server CLI are migrated”。迁移目前只迈了一步:RemoteSession 合入 coding-agent,未文档化,未默认启用;PiSessionBackend 的实现仅 testing/ 目录中用于一致性测试的那一个(testing/backend.ts)。这个测试实现表明接口当前的角色是先固定协议行为,尚未将 AgentHarness 桥接为生产后端。当前仓库同时保留 legacy 子进程路径,并提供尚未默认启用的 RemoteSession 与测试 backend。

PiServer 的进程与关闭方式

可以将 PiServer 理解为一个在进程内管理连接和活跃会话的服务对象,但它本身不等同于“监听某个端口的服务”。PiServer 接收的是 PiServerListener[]:每个 listener 负责接受有序字节连接,再将连接交给 PiServer 处理(packages/server/src/listener.ts:3-9)。因此,一个 PiServer 实例可以绑定一个或多个 listener。当前内建实现是 Unix domain socket,不是 TCP 端口;README 中的 createUnixServer() 只是将 Unix listener 和 PiServer 组合起来的便捷入口。若后续接入 TCP 或 WebSocket,只需提供同样的 listener 与字节连接实现。

代码没有提供跨进程的“全局唯一服务”机制。是否全局只启动一个实例,由宿主程序的部署方式决定。对于一台开发机上的常驻服务,通常启动一个进程、绑定一个稳定的 socket 路径,让多个客户端连接即可。测试、CI 任务或嵌入式调用则可以按任务创建一个实例,但每个实例仍需要独立的 listener 地址、token 和 backend。多个实例若绑定同一个 Unix socket 路径会发生绑定冲突,多个实例若共用同一存储后端,还需要由 backend 处理并发访问和数据一致性。

CI 中应显式关闭服务。PiServer.close() 会先关闭所有 listener,再关闭已有连接、执行会话管理器的清理(packages/server/src/server.ts:153-168, 339-349)。Unix listener 在关闭时还会断开连接并移除由它创建的 socket 文件(packages/server/src/transports/unix/listener.ts:139-157)。测试代码也在 afterEach 中调用 await server.close()。因此,启动服务后应在 finally 或测试框架的清理钩子中调用 await server.close();不要只依赖 CI 进程退出。若服务由独立进程启动,则应在任务结束时向该进程发送终止信号,并等待其清理逻辑完成。

const server = createUnixServer(backend, options);
await server.start();

try {
  await runIntegrationChecks();
} finally {
  await server.close();
}

收尾

pi 正在把“前端启动并独占一个 agent 子进程”的模型,改为“前端连接一个会话服务”的模型。为此,代码先定义会话存储接口,再补齐可在任意有序字节传输上运行的协议,最后实现 attach、会话快照和并发操作检查。客户端可以连接、创建或挂载会话;服务端负责认证、命令分发、会话生命周期和快照广播;repository 负责保存会话数据。

这套设计刻意缩小了跨进程协议的范围。协议只包含 9 个会话命令,传输完整快照和少量控制指令;工具执行、compaction 和扩展事件仍留在服务端运行时。客户端以成功响应和 SessionSnapshot 更新本地状态,progress 仅用于显示当前生成过程。连接中断后,调用方需要自行重连并重新 attach;闪断期间的 progress 不会补发。

不过它还不是可替换旧路径的完整服务端。PiSessionBackend 没有生产实现,RemoteSession 未默认启用,legacy 的 JSONL IPC、supervisor 和 server CLI 仍在使用。当前提交的价值是先明确存储、传输和会话状态的边界,并通过协议 schema、适配层和一致性测试约束后续实现。生产 backend 接入 AgentHarness,以及 CLI 与旧 supervisor 的迁移,仍是这条路径未完成的部分。

下一篇讨论 pi-tui 的行数组差分渲染:组件产出字符串行,渲染器比较行数组并重绘变化区间。


936 字 · 47 段落
xi ming

Written by xi mingFollow onGitHub