13-源码结构与设计思想
13 源码结构与设计思想
读这一篇的价值:前 12 篇都是「怎么用、能配什么」,这一篇回答「它是怎么搭起来的、为什么这么搭」。看懂分层与设计取舍后,再回看任何一篇文档都能定位到对应代码位置,也能判断一次改动该落在哪一层。
1. 仓库与包布局
官方仓库是 monorepo(github.com/earendil-works/pi),packages/coding-agent 是终端代理本体,其余包是被它组装的能力块:
| 包 | 职责 |
|---|---|
ai | 模型请求与流式:供应商 API 实现、消息/内容块类型、用量与成本 |
agent | 通用 agent 循环:消息状态、工具执行、可扩展的 AgentMessage |
protocol / client / server | RPC / 客户端 / 服务端协议与实现 |
tui | 终端 UI 框架(渲染、组件、输入) |
chord | 插件 / 多面(facet)构建框架,实验性 plugin 包用它拆出 Session-worker 与 TUI 面 |
codemode | 用 TypeScript 脚本调用工具的能力(QuickJS WASI 沙箱) |
mcp | MCP 客户端与工具桥接 |
telemetry | 安装/更新遥测 |
coding-agent | 终端代理:会话、工具、模式、扩展、配置、分发 |
包的依赖方向是单向的:coding-agent 依赖 ai/agent/tui/codemode/mcp,反过来不成立。所以**「能力块不加策略」**:ai 只管怎么说协议,不知道什么是项目信任;策略(信任、权限、上下文组装、压缩)全在 coding-agent。
coding-agent 的运行时依赖(v1.0.0)可分成几类,也说明了它的职责边界:
- Pi 自家包:
@earendil-works/{chord,pi-agent-core,pi-ai,pi-codemode,pi-mcp,pi-tui}。 - 编码与运行扩展:
jiti(直接执行 TS/JS 扩展)、quickjs-wasi(codemode 沙箱)。 - 工具实现:
cross-spawn(子进程)、diff(编辑展示)、ignore与minimatch(路径与忽略规则)、proper-lockfile(文件锁)、hosted-git-info(包来源解析)。 - 终端与展示:
chalk、highlight.js、marked、grok-mermaid、@silvia-odwyer/photon-node(图片缩放)。 - 其他:
semver、yaml、typebox(工具参数 schema)、undici(HTTP)。
engines.node >= 22.19.0,type: module(纯 ESM)。
2. coding-agent 内部分层
从已发布包的 dist/ 能直接读出顶层分层(目录结构与 src/ 一一对应):
| 目录 | 职责 | 典型文件 |
|---|---|---|
cli/ | 命令行入口与参数、认证命令、配置选择器 | args.js、auth-check.js、auth-command.js、config-selector.js |
core/ | 领域核心:会话、压缩、工具执行、模型目录、设置、扩展运行时 | agent-session.js、agent-session-runtime.js、agent-session-services.js、bash-executor.js、cache-warmer.js、cache-stats.js、compaction/ |
modes/ | 四种运行模式各自的驱动 | print-mode.js、json-event.js、rpc/、interactive/ |
extensions/ | 内置与内置扩展 | codemode/(含 worker.js)、MCP、llama.cpp、tool search |
utils/ | 通用工具 | 路径、进程、文本等 |
bun/ | 单文件二进制模式下的入口与运行时引导 | cli.js、runtime-setup.js、sandbox-env-setup.js、restore-sandbox-env.js |
bundle/ | 打包产物入口 | cli.js、index.js、rpc-entry.js、cli-runtime.js、chunks/ |
分层规则很干净:
modes/只做输入输出适配:print-mode取最终文本、json-event序列化事件、rpc/收发 JSONL 命令、interactive/渲染终端——它们不实现会话逻辑,全部委托core/。modes/json-event.ts就是「SDK 事件 → 线上增量事件」的转换层(见第 11 篇的JsonAgentSessionEvent)。core/不知道界面:它发出事件、接受 abort signal、读写设置与会话文件,不碰终端。cli/只做解析与装配:把参数、环境变量、配置文件翻译成core/的服务实例。extensions/与内置扩展同构:CLI 把 codemode、tool search、MCP 当内置扩展加载,与用户扩展走同一套注册机制,所以扩展能做的事内置能力不会被特殊对待(差别只在builtin: true的加载时序与可禁用性)。
3. 核心对象与职责
| 对象 | 是谁 | 拥有什么 |
|---|---|---|
AgentSession | 一次对话 | 消息、模型与思考级别、活动工具、排队消息、压缩状态、扩展运行时 |
AgentSessionRuntime | 会话的宿主 | newSession() / switchSession() / fork() / importFromJsonl();每次操作替换活动 AgentSession |
SessionManager | 会话存储 | 条目树、活动叶子、持久化 JSONL;重建模型上下文时它才是权威 |
ModelRuntime | 模型访问 | 供应商与模型目录、认证、streamSimple()、classify()、generateImages()、虚拟模型注册 |
ResourceLoader(DefaultResourceLoader) | 资源发现 | 扩展、技能、提示模板、主题、上下文文件 |
SettingsManager | 配置 | 代理目录与项目设置的合并读取 |
Tool | 一个能力 | 名字、描述、参数 schema、执行函数、渲染器、权限与截断策略 |
| 扩展运行时 | 事件总线 + 注册表 | 工具、命令、快捷键、供应商、事件处理器、渲染器、UI |
关键约束(前一章反复出现的坑,本质都来自这张表):上下文由 SessionManager 的活动分支重建,所以直接改 session.agent.state.messages 不会改变下一次请求;替换会话会换掉整个 AgentSession,所以订阅要重绑。
4. 一次请求的完整数据流
官方把 agent loop 描述得很短,但每一环都在 core/ 里:
- 输入变成一条条目:用户提交的内容先经过提示模板展开与扩展命令处理;文件/图片/粘贴文本/
!shell 输出都可能成为消息内容。最后它作为一条条目追加到活动分支。 - 组装请求:system prompt = 基础指令 + 发现的上下文文件(AGENTS.md 等)+ 技能描述(完整技能指令按需加载,不预热)+ 工具声明;历史 = 活动分支的条目转换成的模型消息。
- 压缩检查:即将超出上下文窗口(或阈值)时先压缩——插入一条 summary 条目,在后续请求里替代更旧的消息,原条目仍留在会话树里。路由到虚拟模型时检查目标物理模型的窗口。
- 发请求:按当前模型设置经
ModelRuntime走对应供应商 API 实现;请求前缀尽量保持稳定,让 prompt cache 命中。 - 流式回包:供应商流被归一化成文本/思考/工具调用块更新,同时发事件。
- 记录 + 执行工具:assistant 消息与每个工具结果都被记入会话;工具在宿主进程权限下执行(
bash走cross-spawn,文件名与路径规则走minimatch/ignore)。 - 下一 turn 还是结束:工具结果或排队消息需要再问一次模型就继续下一个 turn,否则本次 run 结束;扩展可以拦住工具调用、改上下文、改渲染。
- settle:自动重试、溢出恢复、压缩重试、steering、follow-up 都可能有后续;
agent_settled才代表不会自动继续。
Steering 与 follow-up 的插入点是分开的(前者在当前 assistant turn 之后,后者在 agent 干完手头的活之后),中断则把排队消息还回编辑器。这三条语义在整个产品里保持一致:TUI、RPC、SDK 都是同一套。
5. 六条贯穿全局的设计思想
5.1 会话树是唯一真源,上下文每次重建
会话是树(每条条目有 ID 与父引用),走一遍树的路径就是一条分支,当前条目所在分支是活动分支。所以「分支」(/tree、fork、clone)是数据结构层面的能力,不是复制一份消息数组的实现技巧;压缩也只是插入一条 summary 条目,历史条目依然可回溯。模型上下文是从分支推导出来的产物,不是被维护的状态——这就是为什么 SessionManager 才是权威。
5.2 prompt cache 优先
几乎所有反直觉的设计都能用这一条解释:
- 压缩摘要作为锚点保留,而不是把历史删掉重写。
- JSON / RPC 的
message_update只发纯增量,不发累积快照(流大小线性增长)。 - 上下文压缩检查放在发请求之前,且按目标模型的窗口判断。
cacheWarming只在收益(≥ $0.05 避免的 cache-miss 成本)与模型声明的缓存寿命都成立时才跑。- 虚拟模型路由鼓励
continuation/retry保持在同一模型上,只在必要时换并接受一次 cache miss。 /session与页脚把 cache 命中、成本就地暴露给用户。
5.3 一切能力都是扩展,内置也是扩展
工具、命令、快捷键、供应商、渲染器、状态栏、权限门禁全是同一个注册表加不同事件点。内置扩展(codemode、tool search、MCP、llama.cpp)与用户扩展同构,所以:能用扩展做的事,官方不会写死一个私有钩子;用户扩展能做危险的事,官方也就必须把它当可信代码对待。
5.4 信任与权限显式化,但不假装沙箱
项目信任解析在加载项目设置与资源之前,上下文文件在信任决定之后加载;但工具与扩展始终用 Pi 进程自己的操作系统权限运行——信任控制的是「加载什么代码」,不是「代码能做什么」。要做隔离只能换一层宿主:宿主权限收窄、整个 Pi 跑在容器里、或只把内置工具的隔离环境做小(官方 docs/security.md 与 docs/containerization.md 讲的就是这三档)。
5.5 接口解耦、错误统一
四种模式(交互 / 打印 / JSON / RPC)与 SDK 共用同一套 agent 与 session 机制,modes/ 只适配输入输出;错误也被收敛成同一形状(JSON-RPC 错误对象、统一的响应/事件终止原因),所以新增一个入口不用重写一遍错误处理。
5.6 数据留在本地,能力按需联网
会话是本地 JSONL 文件(可以用 sessionDir 改到任意位置,甚至全内存);扩展直接跑 TypeScript;codemode 在 QuickJS WASI 沙箱里跑脚本;图片缩放用本地 photon(WASM);遥测与更新检查都能关(enableInstallTelemetry、PI_OFFLINE、PI_SKIP_VERSION_CHECK);生成的图片不落盘。
6. 构建、打包与分发
coding-agent 的构建链有三层产物,对应三种使用方式:
| 产物 | 命令 | 用途 |
|---|---|---|
| 未打包 JS | build:unbundled(tsc + 拷贝资源) | 开发、troubleshooting(有 .js.map) |
| 打包入口 | node ../../scripts/build-coding-agent-bundle.mjs | npm 分发的 bin: pi → dist/bundle/cli.js |
| 单文件二进制 | build:binary(先构建各依赖包,再 bun build --compile) | 无需 Node 的独立可执行文件 |
- 入口有三处:
dist/bundle/cli.js(CLI)、dist/bundle/rpc-entry.js(导出为./rpc-entry,RPC 子进程入口)、dist/index.js(SDK 主入口,main+types)。 exports还暴露./client与./experimental/plugin,但标为source指向src/——它们不进 npm 包(files里!dist/client、!dist/experimental),只能在仓库内用。- 资源靠拷贝脚本落位:主题 JSON、启动图 PNG、
export-html模板与 vendor 脚本;二进制产物还要额外把docs/、examples/、photon的 wasm 拷进去。 - 包内
piConfig.configDir = ".pi"声明配置目录名;bin决定可执行文件名——改这两个就是一次「换名发行」。 - 依赖锁得很死:
npm-shrinkwrap.json随包发布,overrides.protobufjs固定传递依赖版本,engines卡 Node 22.19+。这是终端工具该有的姿态:用户不该因为装它而被升级成一个不可复现的依赖图。
7. 怎么读源码、怎么调试
- 先看
docs/而非dist/:官方文档与代码同步维护,docs/how-pi-works.md一页就把 agent loop、context、session、interfaces、resources、trust 讲完了,是理解全貌的入口。 examples/是活的规格说明:examples/sdk/(14 个递进示例)与examples/extensions/(几十个可运行扩展:生命周期钩子、安全门禁、上下文修改、自定义工具、UI、Git 集成、自定义供应商)比任何 API 文档都权威,且参与类型检查。docs/session-format.md定义会话 JSONL 的条目形状,要看「树」到底长什么样就读它(第 06 篇的会话机制篇会引用它)。- 想看实现:npm 包里有
dist/**/*.js.map,可以直接跳到原始 TS 位置;仓库里的路径按文档引用为准,例如packages/coding-agent/src/modes/json-event.ts(JSON 事件转换)、packages/coding-agent/src/core/messages.ts(coding-agent 消息角色)、packages/ai/src/types.ts(供应商消息与内容块)、packages/agent/src/types.ts(可扩展消息联合)、packages/ai/src/api/(各供应商 API 实现)、packages/ai/test(供应商行为测试)。 - 想验证行为:
packages/coding-agent用vitest;改供应商实现要跑对应测试套件;改 RPC/JSON 契约要同时看docs/rpc-commands.md与docs/json.md,并在客户端侧按第 11 篇的帧规则写测试。 - 改自己的 Pi(不改上游):把逻辑写成
.pi/extensions/下的扩展或~/.pi/agent/extensions/下的用户扩展,跑/reload。只有当你要做的是「换一套运行时语义」时才需要碰源码——那时第一件事是给上游提 issue,而不是先 fork。
8. 关键文件速查
| 想弄清什么 | 先看哪里 |
|---|---|
| 全貌与概念定义 | docs/how-pi-works.md |
| 会话文件格式(条目、分支、压缩) | docs/session-format.md |
| 上下文与压缩策略 | docs/compaction.md、docs/usage.md |
| 工具与权限模式 | docs/security.md、docs/cli.md |
| 扩展 API 与事件 | docs/extensions.md、examples/extensions/ |
| SDK API | docs/sdk.md、examples/sdk/ |
| 契约(JSON / RPC / 消息) | docs/json.md、docs/rpc*.md、docs/message-types.md |
| 模型与供应商 | docs/models.md、docs/providers.md、docs/custom-provider.md |
| 平台安装与容器隔离 | docs/windows.md、docs/tmux.md、docs/termux.md、docs/containerization.md |
| 已知变更 | CHANGELOG.md |
9. 这一篇的结论
Pi 的设计几乎没有「聪明技巧」,全是几条原则的反复执行:
- 把状态收敛到一个真源(会话树),其他一切从它推导;
- 把变化降到缓存前缀之外(压缩当锚点、流走增量、路由尽量粘);
- 把能力做成同构扩展点(内置不特殊);
- 把风险明说而不假装已解决(信任不沙箱、扩展是可信代码);
- 把入口与实现解耦(四种模式 + SDK 共用一个内核)。
理解了这五条,再去读任何一篇官方文档,都能判断那一节的规则到底在保护什么;反过来,当你要给 Pi 加东西时,也能判断该落在扩展层、配置层还是内核层。
溯源
- 官方
docs/how-pi-works.md、docs/index.md、docs/security.md、docs/containerization.md、README.md - npm 包事实:
package.json(v1.0.0,bin: pi → dist/bundle/cli.js,piConfig.configDir = ".pi",engines.node >= 22.19.0,依赖与files白名单)、dist/目录分层 examples/README.md(示例目录用途)