源码篇第1章 地图的比例尺——pi-mono

小A读完概念篇,信心满满地打开了 pi-mono。三分钟后,他盯着屏幕发呆:packages/ 下面躺着十几个目录,ai、agent、tui、coding-agent、protocol……他随手点开一个,又是几百个文件。
“老Z,”他喊道,“这地图也太大了。我该从哪个目录开始?”
老Z走过来,看了一眼屏幕,反问:“概念篇留下的问题是什么?”
“模型、循环、会话和终端各自的边界,在一套真实工程里究竟落在哪里。”小A背得很熟。
“对。你不是来‘读完 Pi’的,是来验证这几个边界的。”老Z说,“所以先别按目录猜,先固定地图的比例尺,再画主路径。”
本篇的已确认事实全部以上游 pi 仓库(github.com/earendil-works/pi,2026-09 由 badlogic/pi-mono 迁移而来)的标签 v1.0.0 为准,提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32,提交日期 2026-10-01。本书沿用 pi-mono 这个名字指这个 monorepo。下列八个包的 package.json 版本均为 1.0.0,而根 package.json 的 monorepo 版本是 0.0.3,不能混用。本书仓库的 pi/ 目录是这个标签的快照,另加了中文注释,路径和符号与上游一致,行号会因注释而偏移。
这个“八个”是本书的阅读范围,不是根 workspace 的总数:packages/ 下共有 13 个包,另外五个是 chord、codemode、durable、mcp、telemetry。chord 与 telemetry 只在用到时点名;codemode 与 mcp 在附录10、附录11 细读;durable 是源码篇第13章的专章对象。根 workspace 还包含扩展示例;packages/evals 是私有工作区包,不是发布包。
Pi 1.0 带来的七项产品变化
小A问:“既然源码基线是 v1.0.0,这一版给使用者增加了什么?”
老Z说:“先看 pi-coding-agent 的 1.0.0 发布说明。它把七项产品变化列在 New Features 下。这里总结的是 1.0 发布时公布的亮点,不表示每项底层能力都在 1.0 才首次出现;例如 Codemode 和 MCP 在更早的版本里已经出现,1.0 继续改进了它们。”
| 1.0 发布亮点 | 具体变化 | 继续阅读 |
|---|---|---|
| 全屏终端界面 | 交互界面默认使用全屏模式;设 tuiMode: "regular" 可恢复普通终端滚屏。 | 源码篇第6章 终端;终端设置 |
| 更精简的 Codemode | 发布说明称默认工具配置下提示词约减少 40%,并改进错误信息,让模型能看到缺失名称的近似项和修复方向。这个比例是发布说明给出的概述,不是所有模型、工具组合都保证的固定值。 | Codemode 文档 |
| 在 Codemode 中生成图像 | 脚本可通过 models.generateImages() 调用图像模型,使用当前会话凭据;生成结果可由 image() 交回模型。 | Codemode 图像生成 |
| Radius 登录 | /login 可登录 Radius,并接着配置它提供的 MCP server。 | Provider 文档 |
| Anthropic 复制授权码登录 | 浏览器在另一台机器上时,可从授权页面复制代码并粘贴回 Pi,适用于远程或无头环境。 | 交互式认证 |
| 加固 MCP OAuth | 可指定授权服务器元数据地址;按 RFC 9207 校验授权响应中的 iss;凭据按 server 名称和 URL 区分;追加权限时保留已授予的 scope。 | MCP OAuth 文档 |
| 只显示启动标题 | quietStartup: "header" 保留版本号和快捷键提示,同时隐藏模型范围与已加载资源列表。 | 终端设置 |
“这七项主要改变交互、模型能力接入和认证体验。”老Z说,“架构层还有一项需要单独记:@earendil-works/pi-durable 在 1.0 首次发布;同一版把实验性 harness 从 pi-agent-core 移除,作为破坏性 API 变更,将耐久会话能力交给 pi-durable。这是包边界调整,不是上面七项 CLI 功能之一,也不表示 Pi 不再支持耐久会话。”(durable 发布记录;agent-core 变更记录)
源码篇后续会在源码篇第5章、源码篇第6章和源码篇第8章解释会话、终端与扩展边界。本节先给出版本地图;要核对完整范围和每条说明,应回到固定标签 v1.0.0 的各包 changelog 与文档。
先固定地图的比例尺
小A:“八个包……是不是八个独立程序?”
“不是。”老Z说,“工作区名和发布包边界能证明可独立导入的单元,不能证明运行时一定独立进程。先读包元数据和导出,再顺着一次输入的控制流找证据。”
比喻:地图的比例尺
先定“一厘米代表多少公里”,地图才不误导。八个包是本书的阅读比例尺,不是仓库的全貌,更不是八个独立进程。
本书所说“八个包”是下表选取的八个产品/基础范围包。包的职责来自其 package.json 的名称、描述、依赖和导出;描述是维护者声明,不是对全部代码行为的证明。
| 包 | npm 包名 | 可确认的边界证据 | 本书沿它追什么 | 暂不在本章断言 |
|---|---|---|---|---|
packages/ai | @earendil-works/pi-ai | 有根、api/*、providers/* 等导出 | 模型、认证、流适配 | 各厂商服务质量 |
packages/agent | @earendil-works/pi-agent-core | 只依赖 pi-ai,只有根导出 | Agent 与循环 | 会话存储(已不在本包) |
packages/tui | @earendil-works/pi-tui | 零内部依赖的终端 UI 包 | 组件与渲染 | Agent 决策 |
packages/coding-agent | @earendil-works/pi-coding-agent | pi 二进制入口,依赖 9 个内部包 | 命令行装配、会话、压缩、工具与扩展 | 所有扩展行为 |
packages/protocol | @earendil-works/pi-protocol | 传输中立的 CBOR 协议包,依赖 chord | 帧、信封 schema、版本 | 网络可靠性 |
packages/client | @earendil-works/pi-client | 依赖 chord、pi-protocol | 远程客户端连接与请求 | 服务端策略 |
packages/server | @earendil-works/pi-server | 描述为 experimental,依赖 chord、pi-protocol | 握手、请求路由、会话附着 | 生产部署保证 |
packages/evals | @earendil-works/pi-evals | 私有评测工作区包 | harness、对照实验与 judge | 产品质量结论 |
小A的目光停在 coding-agent 那行:“它依赖九个内部包?那 agent 呢?”
“agent 只直接依赖 AI。”老Z说,“这也是这张表里最容易跳过的一点:通用循环被设计成不必直接知道终端、远程协议或会话文件。v1.0.0 还把原来放在 agent 包里的实验性 harness 整个移走了,会话和压缩落到了 coding-agent。由此可以推断,通用循环是独立于产品外壳的;但不能由依赖图推出任何一个产品都必须以同一种方式持久化会话。”
八个包分三层:谁依赖谁
小A看着表格还是觉得散。老Z拿过笔,在纸上画了一张三层图:“按依赖方向分层,关系一目了然。箭头表示‘依赖’,上层调下层,下层不知道上层存在。括号里是本书不整包展开、但会点名的包。”
┌─────────────────────────────────────────────────────────┐
│ 产品层 │
│ ┌────────────────────────────┐ ┌──────────────┐ │
│ │ pi-coding-agent │ │ pi-evals │ │
│ │ (命令行产品、会话、工具、扩展) │◀──│ (私有评测) │ │
│ └──────┬──────────┬──────────┘ └──────────────┘ │
└─────────┼──────────┼─────────────────────────────────────┘
│ │
┌─────────┼──────────┼─────────────────────────────────────┐
│ 核心层 ▼ ▼ │
│ ┌──────────────┐ ┌────────────┐ ┌────────────┐ │
│ │pi-agent-core │ │ pi-client │ │ pi-server │ │
│ │(Agent + 循环) │ │(远程客户端) │ │(请求路由) │ │
│ └──────┬───────┘ └─────┬──────┘ └─────┬──────┘ │
└─────────┼────────────────┼───────────────┼────────────────┘
│ └───────┬───────┘
┌─────────┼────────────────────────┼────────────────────────┐
│ 基础层 ▼ ▼ │
│ ┌────────┐ ┌─────────┐ ┌────────────┐ │
│ │ pi-ai │ │ pi-tui │ │pi-protocol │ │
│ │(模型流) │ │(终端UI) │ │(协议编解码) │ │
│ └────────┘ └─────────┘ └────────────┘ │
│ (telemetry、chord、codemode、mcp、durable) │
└───────────────────────────────────────────────────────────┘“读这张图有三个要点。”老Z说:
- 基础层三个包互不依赖:pi-ai 只管模型流,pi-tui 只管终端,pi-protocol 只管字节编解码。pi-ai 依赖 telemetry,pi-protocol 依赖 chord,这两个本书只在用到时点名。
- 核心层搭在基础层上:pi-agent-core 只依赖 pi-ai;pi-client 和 pi-server 都依赖 pi-protocol 和 chord。
v1.0.0的 pi-server 不再依赖 AI 或 CLI,它只做握手、路由和附着。- 产品层把下面的零件装配起来:pi-coding-agent 直接依赖 agent、ai、tui、protocol、client、server,以及 chord、codemode、mcp。pi-evals 作为开发依赖使用 ai 和 coding-agent。产品层最厚,
v1.0.0里会话和压缩也在这一层。
小A:“那读源码该从哪层开始?”
“从核心路径开始读。pi-ai 和 pi-agent-core 是整条主路径的脊梁。基础层按需穿插(读到协议再翻 protocol,读到终端再翻 tui)。”老Z说,“这就是本书源码篇 13 章的阅读顺序:先源码篇第2–5章打通模型、循环和会话,再源码篇第6–8章看终端和产品,最后源码篇第9–11章看协议、远程和评测;源码篇第12章收束端到端主线。源码篇第13章再补一台引擎——pi-durable 怎么用原子提交和任务检查点回答‘会话凭什么活过崩溃’。”
从公开导出辨认可替换面
小A:“入口在哪儿?就从 index.ts 开始看吗?”
“对,但先分清根入口和子路径。”老Z打开 packages/ai/src/index.ts:
// packages/ai/src/index.ts(模块入口;v1.0.0,节选)
// Core only, side-effect free: no generated catalogs, no provider factories,
// no api-registry, no OAuth implementations, no compat. Provider factories
// live under "@earendil-works/pi-ai/providers/*", API implementations under
// "@earendil-works/pi-ai/api/*", the old global API under
// "@earendil-works/pi-ai/compat".
export * from "./api/lazy.ts";
// ……其余为类型导出与 models、types 等模块“注意开头的注释:根入口只放核心、没有副作用;Provider 工厂在 providers/*,API 实现在 api/*,旧的全局 API 在 compat。这不是单纯的目录约定,package.json 的 exports 也列出了这条子路径边界。”老Z说,“上面的代码只说明根入口可得到哪些 API,一次请求按什么顺序走,这里看不出来。”
他又打开 packages/agent/src/index.ts:“v1.0.0 这边只剩五行:导出 agent.ts、agent-loop.ts、proxy.ts、types.ts 和 setDefaultStreamFn。会话存储、压缩和 skills 都不在这里。它说明通用 Agent 不绑定任何持久化方式,把‘Agent’等同于‘必定写 JSONL’是错误前提;写 JSONL 的是 coding-agent。”
| 导出层 | 调用者能依赖的东西 | 不能从这里推出的结论 |
|---|---|---|
pi-ai 根入口 | Models、事件流、类型、懒加载工具 | 已注册哪些 Provider |
pi-ai/providers/* | 某个 Provider 工厂 | 认证已配置或模型一定可用 |
pi-ai/api/* | 某种 API 的请求/转换实现 | 其他 API 也具备相同行为 |
pi-agent-core 根入口 | Agent、循环与回合插口 | 宿主采用哪种存储或 UI |
pi-coding-agent 二进制 | pi CLI 的产品入口 | 远程模式或所有扩展已启用 |
一条主路径,而非完整架构图
小A:“那我到底沿着哪条线读?总不能把每个包都读一遍。”
“接下来主干上的章节按控制流走,不按目录念文件名。”老Z画了一条带站号的主路径,每一站就是一章,从模型请求出发,经过循环和会话,最后拼回完整请求。源码篇第13章不在这条主干上:它另起一台引擎——pi-durable——回答源码篇第5章留下的崩溃恢复问题:
①模型请求 ②流式适配 ③循环推动 ④会话存活
┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│ch2 │──▶│ch3 │──▶│ch4 │──▶│ch5 │
│pi-ai │ │pi-ai │ │pi-agent│ │coding- │
│models.ts│ │lazy.ts │ │loop.ts │ │agent 会话 │
└────────┘ └────────┘ └────────┘ └──────────┘
│
⑤终端刷新 ⑥产品启动 ⑦工具信任 ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ch6 │ │ch7 │──▶│ch8 │ │ch12 │
│pi-tui │ │coding- │ │coding- │ │端到端 │
│main-scr│ │agent │ │agent │ │完整请求│
└────────┘ └────────┘ └────────┘ └────────┘
│ │ ▲
⑧协议字节 ⑨远程一致 ⑩评测判断 │
┌────────┐ ┌────────┐ ┌────────┐ │
│ch9 │──▶│ch10 │ │ch11 │──────┘
│protocol│ │server │ │evals │
│codec.ts│ │router │ │harness │
└────────┘ └────────┘ └────────┘“这张图要分组读。”老Z说:
- 主线是 ①→②→③→④:模型请求出发,流穿过适配层,循环推动状态,会话活过进程。前三站在 pi-ai 和 pi-agent-core;
v1.0.0的会话在 coding-agent,所以第④站已经进入产品层的核心部件。先读这四章。- ⑤⑥⑦是产品层:终端怎么刷新、产品怎么启动、工具和信任怎么接。它们搭在核心层之上,第二组读。
- ⑧⑨⑩是远程和评测:协议怎么变字节、远程怎么保持一致、评测怎么判断。它们是独立的能力边界,按需读。最后 ch12 把所有站拼成一次完整请求。
每章读哪个包的哪些文件
小A:“那具体到文件呢?我打开 ch2,到底该翻开哪个文件?”
老Z列了一张完整对照表:“这张表是你读源码篇的导航仪。打开任何一章,先看它在哪个包、读哪些核心文件。”
| 章 | 你在读哪个包 | 核心文件(打开这几个就够) | 这一站回答什么 |
|---|---|---|---|
| 1 地图 | 全部八包 | package.json × 8 | 八包怎么分层、主路径怎么走 |
| 2 模型请求 | pi-ai | models.ts、auth/resolve.ts | 一次请求怎么从认证走到 Provider |
| 3 流式适配 | pi-ai | api/lazy.ts、api/openai-responses.ts | 厂商事件怎么成为统一事件 |
| 4 循环 | pi-agent-core | agent.ts、agent-loop.ts | 模型输出怎么推动状态前进 |
| 5 会话 | pi-coding-agent | core/agent-session.ts、core/session-manager.ts | 会话怎么活过一次进程 |
| 6 终端 | pi-tui | tui-main-screen.ts、stdin-buffer.ts | 终端为什么能稳定刷新 |
| 7 启动 | pi-coding-agent | main.ts、core/agent-session-services.ts | 命令行产品怎么启动 |
| 8 工具信任 | pi-coding-agent | tools/file-mutation-queue.ts、extensions/runner.ts | 工具和扩展怎么安全接入 |
| 9 协议 | pi-protocol | codec.ts、framing.ts、protocol.ts | 消息怎么变成可靠字节 |
| 10 远程 | pi-server + pi-client | server.ts、session-router.ts、client.ts | 远程会话怎么握手、路由和附着 |
| 11 评测 | pi-evals | src/harness.ts、src/plan.ts、src/report.ts | 评测怎么判断一次修改 |
| 12 端到端 | 跨全部包 | (回顾源码篇第2-11章的文件) | 一次完整请求怎么走完 |
| 13 耐久运行时 | pi-durable | session/session.ts、harness/scheduler.ts、harness/tool.ts、storage/ | 一次提交与工具调用怎样在崩溃后恢复 |
“注意,这条线上有两条数据,别混成一条。”老Z又列了一张表:
| 数据 | 在主路径中的形状 | 关键边界 |
|---|---|---|
| 配置 | model、headers、认证、signal、重试选项 | ModelsImpl.applyAuth 合并后才交 Provider |
| 上下文 | AgentMessage[],调用模型前转为 AI Message[] | streamAssistantResponse 的 convertToLlm |
| 生成 | AssistantMessageEventStream 的 start、delta、done/error | adapter 产生,loop 消费 |
| 副作用 | tool result、session append、终端刷新 | Agent event 与 AgentSession 写会话并非同一件事 |
小A:“为什么不直接从 CLI 入口开始,一路读到网络请求?那不是更直观吗?”
“那条线会同时带入配置、交互模式、终端和扩展。”老Z说,“先锁住跨包稳定的流与循环边界,才不会把某个产品装配误当成核心机制。之后的 源码篇第7章 户口本上的住址——pi-coding-agent 会再把 CLI 接回这条线。”
阅读时如何保持证据强度
小A翻开笔记本,准备开始读代码。老Z拦住他:“先记住:源码走查容易犯三类错误,我踩过,别踩。”
阅读时三类常见错误
- 把注释或描述扩张为运行时保证:
pi-server的描述含 experimental,只能确认它自称实验性,不能自行补成“不适用于任何生产场景”。- 把类型可选性误读为实现一定缺失:
Provider.refreshModels?是可选方法,代表静态 Provider 可以没有刷新路径;不表示所有动态 Provider 都会成功刷新。- 把一个错误结果压扁成
false:未知 Provider、未配置认证、流中止、工具失败、JSONL 解析失败所在层不同,恢复策略也不同。后续章节会把它们放在同一张失败语义表里比较。
“推荐按这个顺序读固定快照:先读导出和类型;再读入口函数的完整分支;随后跟到下一包的接口实现;最后用测试或一个可控调用验证‘正常、错误、中止’三条路径。遇到跨层推断时,标记为推断,不替源码补作者动机。”
这套切分的收益与代价
“这样切分,值吗?”小A问。
“工程观点:以包边界加调用链组织阅读,收益是替换模型适配器、会话后端或 UI 时可以先找契约,不必先理解所有产品代码。”老Z说,“代价是读者要在多处源文件之间往返,且不看具体宿主时不会知道默认配置从哪里来。”
小A接着问:“那本章故意没讲什么?”
“各 Provider 的完整模型目录、每个 API 的协议字段、工具定义、TUI 的事件订阅实现、Protocol 的 framing、Server/Client 的重连,以及评测用例。”老Z说,“这些留给对应的调用链章节,本章不展开。”
把主路径拆成可停下来的阅读站
小A:“每章读多长合适?一口气跟完整条链?”
“别。源码阅读不该把一条链当作必须一次读完的长函数,每到一个公开类型或事件边界就停下来,写下输入、输出与失败结果。这比只记录‘这个包做什么’更容易发现跨层假设。”老Z给了一张“阅读站”表:
| 阅读站 | 最小问题 | 下一站 | 失败先看哪里 |
|---|---|---|---|
| 模型选择 | Model.provider 指向谁 | ModelsImpl | 未知 Provider |
| 请求准备 | credential 与 options 如何合并 | Provider.stream* | auth、header transform |
| 流适配 | 上游事件如何成为统一事件 | runLoop | error、aborted、pending |
| Agent turn | 什么进入下一次 context | 工具执行/队列 | length、tool error、terminate |
| 会话持久化 | 哪些 event 被追加 | AgentSession/SessionManager | 写入失败、坏行被跳过 |
| 宿主显示 | 谁订阅事件并刷新 | TUI | 渲染、终端、输入边界 |
“举个例:一个网络失败,不该直接从‘请求准备’跳到‘终端显示’解释。先确认 Provider 是否将其归一为 error event;再确认 loop 是否把最终 error message 写入 context;最后才看会话层或 UI 是否记录或显示它。”老Z说,“这是一种阅读顺序,不是源码承诺的运行时重试链。”
小A:“那每章只读一个函数,会不会错过跨文件状态?”
“不会只读一个函数;关键是每章规定一个入口、一个出口和几条分支。”老Z说,“当函数跨文件调用时,继续跟其直接契约,而不是先把整个包读完。在固定提交上,git grep 可以验证符号存在,但符号存在不能代替从调用点读到返回点,类型名相同也不代表语义相同。例如 AI 层的 Context 与会话树条目处在不同抽象层,本书会在每个边界显式标出这一点。”
小结
这一章解决的是源码篇第一站的问题:面对 pi-mono 十几个包,该从哪下手。答案不是按目录读,而是先固定三个坐标:版本、依赖方向、主路径。
- 版本坐标:基线是上游标签
v1.0.0(提交a13d35a7);本书选取的八个包都是1.0.0(pi-evals是私有工作区包),根 monorepo 是0.0.3,两者不能混用。八个包是阅读范围,仓库共有 13 个包。 - 依赖坐标:八包分三层。基础层(ai、tui、protocol)互不依赖;核心层(agent-core、client、server)搭在基础上;产品层(coding-agent,以及使用它的 evals)负责装配,
v1.0.0里会话和压缩也在这一层。通用循环被设计成不必直接知道终端、远程协议或会话文件。 - 路径坐标:主路径把“输入到流、流到循环、循环到会话/UI”拆为可分别验证的边界,每一站就是一章。
记住
包边界不是进程边界。工作区名和依赖图能证明“可独立导入的单元”,不能证明运行时一定是独立进程。读源码时,先按这三层坐标定位自己,再沿调用链走,而不是逐个目录念文件名。
源码走查
- 克隆上游仓库并检出标签:
git clone https://github.com/earendil-works/pi && cd pi && git checkout v1.0.0,再执行git rev-parse HEAD;预期完整输出是a13d35a742c6ef8462812a28fbe1d8c8b7431c32。本书仓库的pi/目录是同一标签的带注释快照,没有自己的 git 历史,路径相同、行号会偏移。 - 执行
node -p 'require("./packages/ai/package.json").version'和同样的 agent 包命令;确认它们是1.0.0,再对照根package.json的version,避免混用版本。 - 打开
packages/ai/package.json的exports;分别记录根入口、./providers/*、./api/*与./compat,不要把子路径当成根导出。 - 打开
packages/agent/package.json的dependencies;确认其中有@earendil-works/pi-ai,再检查它没有pi-tui,以验证上表的直接依赖说法。 - 逐行读
packages/ai/src/index.ts的开头注释及导出;区分运行时export *和只导出类型的export type。 - 读
packages/agent/src/index.ts的五行导出;再在packages/coding-agent/src/core/下找到session-manager.ts、compaction/和skills.ts,确认会话、压缩和 Skills 在v1.0.0属于产品层。
