补全章节说明:原教程(dg-ai-notes)插图版只覆盖到第 10 章。本章依据原作者仓库中留存的第 11 章设计文档(_chapter-design/第11章-扩展系统-设计文档.md),结合 Pi v0.81.1 官方源码(packages/coding-agent/src/core/extensions/)与官方文档(docs/extensions.md)补全,行文风格与粒度对齐前 10 章。源码行号以 v0.81.1 为准,与前 10 章引用的 v0.80.2 可能有少量偏移。
前 10 章里,Pi 的核心机制都讲完了:Agent Loop、模型调用、工具系统、消息系统、事件驱动、上下文工程、压缩、会话管理。你可能注意到一个现象——这些核心都很克制(extensions 目录四个核心文件加起来约 3700 行),但 Pi 的实际能力远超核心代码的覆盖范围。
比如:禁止 rm -rf、接入本地 Ollama、给 Agent 加一个查数据库的 SQL 工具、在终端里跑 Doom——这些 Pi 核心代码里统统没有。它们从哪儿来?
答案是扩展系统。这一章我们打开它。
先看六个真实场景。它们都是 Pi 用户在实际工作里遇到的需求:
第一反应可能是:给 Pi 提 PR?等官方排期,还不一定收。改 Pi 源码?可以,但每次 npm update 都要重新 merge 一遍你的私改——第 7 章开头讲"工具调用日志"时算过这笔账,维护成本会随时间无限累积。
更根本的矛盾是:Pi 不可能预先知道你团队要禁 rm -rf。 这些需求高度个性化,任何"官方内置"都覆盖不全。第 1 章讲过 Pi 的减法哲学——不做 MCP、不做子 Agent、不做权限弹窗。Pi 官方 README 的说法是:
"Pi is aggressively extensible so it doesn't have to dictate your workflow." (Pi 激进地可扩展,所以它不必替你决定工作流。)
"激进地可扩展"落到工程上,就是本章的主角。先看它长什么样。
把下面这个文件放到 ~/.pi/agent/extensions/permission-gate.ts,重启 Pi(或 /reload),需求 1 就解决了:
这是官方仓库 examples/extensions/permission-gate.ts 的真实代码(略有精简)。三十行,不碰 Pi 一行源码:
Pi 官方对扩展的定义是:"TypeScript modules that extend pi with custom tools, commands, keyboard shortcuts, event handlers, and UI components"(用自定义工具、命令、快捷键、事件处理器和 UI 组件扩展 Pi 的 TypeScript 模块)。上面六个需求全部有对应的官方示例:需求 2 是 registerProvider,需求 3 是 registerTool,需求 4 是 context 事件,需求 5 是 registerCommand,需求 6 是 session_before_switch 事件。
看起来很美好。但如果你停下来想一下,会发现一个不对劲的地方。
permission-gate.ts 是你写的文件,Pi 的源码里没有它的任何信息。Pi 发布的时候,作者 Mario 根本不知道世界上会有你这个扩展、你要拦 rm -rf。那凭什么你返回一个 { block: true },Pi 的工具管线就乖乖停下来?
如果让你来设计,直觉方案可能是"插入代码"——把扩展代码注入到工具执行的路径里。但这意味着 Pi 核心要理解你的代码逻辑,耦合太深,一崩全崩。
Pi 的答案是第 7 章讲过的老朋友:事件。但用法升了一级。
回忆第 7 章:Agent 内核有 10 种 AgentEvent,通过 subscribe(listener) 分发给 TUI、持久化这些消费者。那是"观测"——听到事件后做自己的事,不影响 Agent。
扩展系统在 coding-agent 层建了另一条更强的事件总线:34 种 ExtensionEvent(定义在 types.ts:1023-1048(repo/packages/coding-agent/src/core/extensions/types.ts)),覆盖 Pi 工作流的八个关键介入点。和第 7 章最大的不同是——很多事件的处理器返回值能改变 Pi 的行为。
用广播电台类比:Pi 在八个频道持续发节目("我要执行工具了""我要调 LLM 了""用户要切换会话了"),扩展订阅自己感兴趣的频道。第 7 章的听众只能听;扩展系统的听众可以打电话进直播间点歌——返回一个值,节目内容就变了。
八个介入点,对应事件分组如下:
| # | 介入点 | 代表事件 | 扩展能干什么 |
|---|---|---|---|
| 1 | 启动与信任 | project_trust、resources_discover | 接管项目信任决策;追加技能/提示词/主题路径 |
| 2 | 会话生命周期 | session_start、session_before_switch/fork/compact/tree、session_shutdown 等 9 种 | 否决会话切换/分叉;自定义压缩摘要;清理资源 |
| 3 | 用户输入 | input、user_bash | 改写输入、直接接管处理 |
| 4 | Agent 启动前 | before_agent_start | 注入消息、改系统提示词 |
| 5 | LLM 调用前后 | context、before_provider_headers/request、after_provider_response | 修改消息列表、改请求头、替换 payload |
| 6 | 工具执行 | tool_call、tool_result、tool_execution_start/update/end | 拦截工具、改参数、改结果 |
| 7 | 消息流 | message_start/update/end | 观测流式输出;替换最终消息 |
| 8 | 模型与思考等级 | model_select、thinking_level_select | 感知切换,联动 UI/初始化 |
完整的 34 种事件清单和返回值协议见文末附表。原教程设计文档写的是"28 个事件"(v0.80.2),到 v0.81.1 已经涨到 34 个——扩展介入点本身也在持续生长。
上表是分类视角。官方文档(docs/extensions.md)还给了一张更形象的时间轴视角图——把所有事件按发生顺序钉在一次完整交互的生命周期上,哪个事件能拦、能改,直接标在括号里:
配图说明(摘自官方 docs/extensions.md「Lifecycle Overview」,略有精简):主干是"用户发一句话"的完整旅程——输入处理 → Agent 启动 → 若干个 turn(每个 turn 内含 LLM 调用前后和工具执行的全部介入点)→ 收尾;下方几段是斜杠命令触发的会话级事件。注意 tool_call 夹在 tool_execution_start 和真正执行之间、context 在每个 turn 的 LLM 调用前都会重新触发——这两个位置感,光看分类表是看不出来的。
关键洞察是:Pi 不需要预先知道你的需求。它只承诺"在这 34 个时机喊一嗓子,并遵守返回值协议"。 你的 { block: true } 之所以有效,不是因为 Pi 理解了"禁止递归删除",而是因为 tool_call 事件的协议里写着"处理器返回 block=true,则跳过工具执行"。
这是一种用"约定"换"灵活"的设计——约定是事件名 + 返回值协议,灵活是任何人不改源码就能改变 Pi 的行为。
34 种事件不是铁板一块。按"返回值能干什么"分,恰好四种模式,力度从弱到强:
模式 1:纯通知(约 20 种) —— 返回值被忽略,只能观测。如 agent_start、turn_end、message_update、tool_execution_*、model_select。用途:状态栏、日志、打点。
模式 2:一票否决 —— 任何一个处理器投反对票,动作立即取消,后面的处理器都不再执行。如 tool_call 返回 { block: true }、session_before_switch/fork/compact/tree 返回 { cancel: true }、project_trust 返回 { trusted: "no" }。
看 runner.ts 里 tool_call 的分发实现(runner.ts:915-936(repo/packages/coding-agent/src/core/extensions/runner.ts)):
双重 for 循环,顺序 = 扩展加载顺序 × 同一扩展内注册顺序。谁先返回 block: true,循环立即终止——一票否决。
模式 3:链式修改 —— 处理器像流水线工人,前一个的输出是后一个的输入。如 tool_result(改工具结果)、context(改消息列表)、message_end(换最终消息)、before_agent_start(改系统提示词)。
tool_result 的链式实现(runner.ts:860-913(repo/packages/coding-agent/src/core/extensions/runner.ts)):每个处理器收到的是上一个处理器修改后的最新事件,返回的 content/details/isError/usage 字段逐一覆盖,省略的字段保持不变。官方文档明确写着 "tool_result handlers chain like middleware"。所以"扩展 A 截断 8000 字符输出到 500 字符 → 扩展 B 给截断后的结果补充元数据"这种协作是天然支持的。
模式 4:短路接管 —— 处理器说"这事我办了,别人(包括 Pi 自己)都不用管"。如 input 返回 { action: "handled" }(跳过 LLM,扩展自己响应),user_bash 返回 { result }(直接给出命令结果,Pi 不再真的执行)。
四种模式对应四种插件系统的经典需求:观测、否决、加工、代理。设计自己的插件系统时,这个分类几乎可以照搬。
把机制串起来,追踪开头那个场景从触发到结束的全链路:
注意第 6 步——这正是第 5 章"错误即消息"原则的复用:拦截不是异常,而是一条普通的工具结果消息。LLM 被告知"为什么被拦",它可以自我修正。整条链路上没有任何一层需要"理解"扩展的逻辑,各层只遵守自己那段协议。
第 5 章讲五步管道时说"第 3 步 beforeToolCall 可以阻止执行",第 7 章说"这个机制由扩展系统实现"——现在你看到了完整的实现。
一票否决讲完了,链式修改也各走一条完整链路。
链路二:给每次 LLM 调用注入当前时间(需求 4)。介入点是 context 事件——Agent Loop 每次调 LLM 前触发,扩展可以修改即将发出的消息列表:
数据的前后变化:
两个实现细节决定了这条链路的安全性(runner.ts:967-997(repo/packages/coding-agent/src/core/extensions/runner.ts)):
链路三:工具结果的流水线加工。介入点是 tool_result 事件。假设装了两个扩展——A 负责截断超长输出,B 负责给结果附加元数据:
emitToolResult 的实现(runner.ts:860-913(repo/packages/coding-agent/src/core/extensions/runner.ts))支持部分补丁:处理器返回的 content/details/isError/usage 四个字段里,给了哪个就覆盖哪个,省略的保持现值。B 只关心 details,就不用碰 A 已经处理好的 content——像流水线上第二个工人不必重做第一个工人的活。
这两条链路加上 rm -rf 拦截,你已经见过四种事件模式里的三种在真实数据上怎么运转。第四种(短路接管)的代表 input 事件套路相同:处理器返回 { action: "handled" },输入就不再进入 Agent——官方示例 input-transform.ts 用它实现了"输入 ping 直接回 pong,不惊动 LLM"。
再看一遍扩展的骨架:
导出一个工厂函数,在里面注册。就这?
就这。但有一个致命的时机问题藏在里面。
工厂函数是在 Pi 启动加载阶段执行的——此时 Pi 正在扫描扩展目录、逐个加载文件。那一刻:Agent 还没创建,SessionManager 还没就绪,Agent Loop 更没启动。
pi 对象上除了 on/registerTool 这些注册方法,还有一批动作方法:pi.sendMessage()(发消息进会话)、pi.setModel()(切模型)、pi.appendEntry()(写会话记录)……
问题来了:如果你在工厂函数里直接调 pi.sendMessage("扩展已加载!"),会发生什么?消息发给谁?会话还不存在!
三个候选方案:
Pi 选了 C。这就是扩展系统里最精妙的设计——两阶段绑定。
Pi 加载扩展前,先创建一个共享的 runtime 对象,所有动作方法都指向一个"抛错桩"(throwing stub)。源码在 loader.ts:170-223(repo/packages/coding-agent/src/core/extensions/loader.ts) 的 createExtensionRuntime:
然后 createExtensionAPI(loader.ts:230-393)构造传给工厂函数的 pi 对象:注册类方法直接写入扩展自己的 Map(on 往 handlers Map 里 push,registerTool 往 tools Map 里放);动作类方法全部委托给上面那个共享 runtime——此刻就是抛错桩。
用入职类比:扩展是新员工,工厂函数是入职流程,pi 对象是它领到的工牌。工牌当场就能用的部分是"登记信息"(注册);工牌上的门禁卡(动作)现在刷了就报警——因为大楼(Agent)还没盖好。
注意 registerProvider 这个特例:它既不算纯注册(要写入 ModelRegistry,核心才有),又不能抛错(动态拉模型列表的扩展就是要在工厂里注册 provider)。Pi 的处理是排队——先记下来,等核心就绪统一执行。三种策略(直接生效 / 抛错 / 排队)在同一个对象上共存,按每个方法的语义分别选择。
Agent、SessionManager 全部就绪后,AgentSession 调用 runner.bindCore(...)(runner.ts:311-408(repo/packages/coding-agent/src/core/extensions/runner.ts),调用点在 agent-session.ts:2359):
妙处在于:替换的是共享 runtime 对象的属性,而所有扩展手里的 pi 都委托给这个对象。不需要通知任何扩展"你可以干活了",下一次调用自动就是真实实现。
从数据视角看两个阶段前后发生了什么:
注册的成果(左三列)在加载期就沉淀进 Extension 对象的几张 Map;动作的能力(右下角)在 bindCore 一瞬间集体上线。
对扩展作者来说,规则简化成一句话:工厂函数里只注册,事件处理器里随便动。在工厂里调动作方法会得到一个明确的报错(而不是静默失败或未定义行为)——这个错误信息本身就是文档。
对比一下没有这套机制的世界:要么动作方法在加载期是 undefined(调用报一个莫名其妙的 TypeError),要么静默丢弃(扩展作者调试半天不知道为什么没效果),要么强行支持(往一个不存在的会话里发消息,状态错乱)。throwing stubs 把"时机错误"变成了"即时、可读的失败"。
和第 7 章的呼应:第 7 章讲过"对内层受信任的监听器让异常直接暴露"。throwing stubs 是同一哲学在另一个维度的应用——错误的调用时机,第一时间用异常暴露出来。
至此扩展已经很强了:能拦工具、改消息、注册 provider。但扩展是第三方代码——你从 GitHub 上装的一个 Pi Package,里面的事件处理器要是抛异常了呢?要是写了个死循环呢?要是恶意扩展想劫持你的会话呢?
Pi 用两道防线回答:错误隔离 + API 门面分层。
看通用 emit() 的实现(runner.ts:784-816(repo/packages/coding-agent/src/core/extensions/runner.ts)):
每个处理器调用都被 try/catch 包裹。扩展崩了,错误被编码成一条错误通知(TUI 里显示出来),下一个处理器照常执行,Agent 不受影响。这正是第 7 章末尾预告的"对外层不受信任的监听器,由框架做隔离"。
但如果你去读 emitToolCall(上面第二节贴过),会发现它没有 try/catch。这是疏忽吗?
不是。是安全语义的刻意选择。调用方 agent-session.ts:480-485 会捕获这个异常并把它转化为"阻断工具执行"。官方文档一句话说明了策略:
"tool_call errors block the tool (fail-safe)."
想想为什么必须这样:假设 permission-gate 扩展自己出了 bug 抛异常,如果按普通错误隔离处理——吞掉异常继续执行——那 rm -rf 就放行了。一个安全扩展的 bug 导致安全检查失效,这是最坏的结果。所以 tool_call 的错误语义是 fail-closed(出错时关门):守门人晕倒了,门必须保持关闭。
两种错误语义的分工:
| 事件类型 | 错误语义 | 理由 |
|---|---|---|
| 观测类、修改类事件 | fail-open:吞错继续 | 一个状态栏扩展的 bug 不该打断编码工作 |
| tool_call 拦截 | fail-closed:出错即拦截 | 安全检查失效时,宁可误拦不可漏放 |
第二个问题:扩展处理器拿到的 ctx,能不能调 switchSession() 把用户会话切走?能不能调 reload() 把所有扩展重载一遍?
不能。因为 Pi 给扩展的从来不是内部对象,而是三层门面(Facade)——每层只暴露那个场景下安全的方法(定义在 types.ts:305-378, 1172-1407(repo/packages/coding-agent/src/core/extensions/types.ts)):
分层的依据不是"信任等级",而是调用时机的安全性:
还有一个容易忽略的细节:ctx.sessionManager 的类型是 ReadonlySessionManager——事件处理器可以读全部会话状态(做决策要用),但写入口只有受控的 pi.appendEntry() 等少数方法。观测自由,修改收口。
用开头的需求 6(清空会话前确认)看这套门面在实战里够不够用。官方示例 confirm-destructive.ts:
注意这个处理器用到的每一样东西都在 ExtensionContext 的能力面内:hasUI 判断运行模式、ui.confirm 弹对话框、sessionManager 读会话状态(完整示例里还检查了"有没有未保存的工作")。它不需要也拿不到 switchSession——否决一个切换和发起一个切换,是两种完全不同量级的权力。门面分层的边界画在这里刚刚好。
第三道保险是 stale 失效:/reload 或会话切换后,旧的 runner 被 invalidate() 标记(runner.ts:539-546),扩展如果捕获了旧的 ctx 还想用,会得到明确报错("stale after session replacement or reload")而不是静默操作一个已死的会话。ctx 的每个属性都是 getter,读取时都先 assertActive()。
一个诚实的边界:这套防线针对的是 bug,不是恶意。扩展是跑在你进程里的任意 TypeScript 代码,能 import fs 就能删你文件——门面拦不住绕过门面的人。官方文档开头就警告:"Extensions run with your full system permissions... Only install from sources you trust."(扩展拥有你的完整系统权限,只安装你信任的来源。)真正的安全边界是容器——这和第 1 章讲的 YOLO 哲学一脉相承。
到目前为止讲的都是"事件"——扩展被动等 Pi 喊话。但回看第一节的六个需求,需求 3(SQL 工具)和需求 5(/deploy 命令)不是"拦截 Pi 做的事",而是"给 Pi 加它原本没有的东西"。这就是扩展系统的另一半:注册型能力。
ExtensionAPI 上的注册方法一共八个:registerTool(LLM 可调用的工具)、registerCommand(斜杠命令)、registerShortcut(快捷键)、registerFlag(CLI 参数)、registerProvider(模型供应商)、registerMessageRenderer / registerEntryRenderer(自定义渲染)、外加 on(事件订阅本身也是注册)。挑最重要的两个走一遍。
需求 3 的实现骨架:
几个值得注意的点,全部呼应前面的章节:
还有一个彩蛋级用法:注册一个与内置工具同名的工具,就能整体替换内置实现(官方示例 tool-override.ts 覆盖了 read 工具)。扩展系统没有为"覆盖内置"设计任何专门机制——同名即覆盖,注册表就是这么朴素。
需求 5,用户输入 /deploy 就执行部署并把结果告诉 Agent:
链路全景:
看最后一步——命令的终点不是"显示结果",而是"把结果变成 Agent 的输入"。这是 Pi 扩展和传统编辑器插件最不一样的地方:传统插件的输出面向人,Pi 扩展的输出可以面向模型,让确定性的脚本世界和推理的模型世界互相接力。第 3 章的 steering/followUp 队列、第 6 章的自定义消息,都是这场接力的基础设施。
加上第 8 章讲过的 Skills,Pi 其实给了三种"加能力"的杠杆,各有最佳射程:
| 杠杆 | 本质 | 适合 | 不适合 |
|---|---|---|---|
| Skill | 一份 Markdown 说明书,LLM 按需读 | 教模型"怎么做某类事"(流程、规范、工具用法) | 需要拦截/改变 Pi 行为 |
| 扩展-注册型 | TS 代码,加工具/命令/供应商 | 给模型新能力、给用户新入口 | 纯知识性内容(杀鸡用牛刀) |
| 扩展-事件型 | TS 代码,挂事件总线 | 改变 Pi 既有行为(拦截、注入、改写) | 独立的新功能 |
经验法则:能用 Markdown 说清楚的用 Skill,要执行代码的用注册型,要改变 Pi 自身行为的才用事件型。 三者可以打包进同一个 Pi Package 分发——这就是第 1 章说的"从用 Pi 到改造 Pi 的平滑升级路径"。
机制都讲完了,最后把视角拉回文件系统:你写好的 .ts 文件,Pi 是怎么找到、加载、接线的?
discoverAndLoadExtensions(loader.ts:673-721(repo/packages/coding-agent/src/core/extensions/loader.ts))按序扫描三处:
| 位置 | 作用域 | 信任要求 |
|---|---|---|
| ~/.pi/agent/extensions/ | 全局(所有项目) | 无 |
| .pi/extensions/(项目内) | 项目本地 | 项目被信任后才加载 |
| settings.json 的 packages/extensions 配置 | 显式指定(npm/git 包) | 包安装即视为信任 |
目录里的规则(单层,不递归):*.ts/*.js 直接加载;子目录则按优先级找入口——package.json 的 pi.extensions 字段 > index.ts > index.js(resolveExtensionEntries,loader.ts:594-624)。这让扩展可以从单文件平滑长大成带 npm 依赖的完整包,目录里放个 package.json + node_modules 就行。
扩展是 TypeScript,Node 不能直接跑。Pi 用 jiti(运行时 TS 转译器)加载(loader.ts:403-428):
记忆点就一个:改了扩展文件,/reload 一下就生效,不需要任何 build 步骤。 两种模式是部署形态的适配:Pi 以单体二进制分发时,typebox、@earendil-works/pi-* 这些包已打进二进制,virtualModules 把扩展的 import 映射到内置模块;以 npm 安装时,alias 把它们指向真实安装路径。扩展作者对此完全无感。
await factory(api)(loader.ts:454-480)。工厂可以是 async 的——Pi 会等它完成再继续启动,所以"先 fetch 内网网关的模型列表再 registerProvider"这种异步初始化是一等公民。执行完毕后,这个扩展的 handlers/tools/commands 几张 Map 就填好了。
Agent 就绪后 bindCore 开通动作方法(第三节讲过)。扩展注册的工具还要过一道 wrapRegisteredTool(wrapper.ts:17-37(repo/packages/coding-agent/src/core/extensions/wrapper.ts),全文件仅 45 行)——把 ToolDefinition 适配成 AgentTool 塞进第 5 章讲的五步管道,从此扩展工具和内置工具走完全相同的验证、执行、错误处理路径。tool_call 拦截对扩展工具同样生效——连你自己注册的工具也会被你自己的 permission-gate 检查。
/reload 时(agent-session.ts:2603-2626):session_shutdown 事件(给扩展清理资源的机会)→ 清扩展模块缓存 → new 一个全新的 ExtensionRunner 重新走发现/加载/绑定 → 对新实例发 session_start(reason: "reload")。旧 runner 整体标记 stale。
这个能力配合"扩展就是 cwd 下的 TS 文件",产生了官方文档开头那句话描述的玩法:
"pi can create extensions. Ask it to build one for your use case." (pi 会写扩展。让它给你的场景造一个。)
让 Agent 给自己写扩展:你对 Pi 说"帮我写个扩展,每次 turn 结束自动 git commit",它用 write 工具生成 .pi/extensions/auto-commit.ts,你 /reload,能力立即上线。Agent 修改自己的能力闭环,就是靠"目录约定 + jiti 免编译 + 热重载"三件套撑起来的。
回顾本章的三个问题与三个答案:
1. Pi 不认识你的需求,怎么扩展?——事件总线。 Pi 在工作流的 8 个介入点 emit 34 种事件,用"事件名 + 返回值协议"这个约定,把改变行为的决定权交给扩展。四种事件模式(通知/否决/链式/接管)覆盖了插件介入的全部力度谱系。
2. 工厂函数早于运行时执行,动作方法怎么办?——两阶段绑定。 注册期动作方法是 throwing stubs(调用即明确报错),核心就绪后 bindCore 把真实实现写进共享 runtime 对象,全体扩展无感切换。特殊需求(registerProvider)用排队策略兜底。
3. 第三方代码崩了/越权了怎么办?——错误隔离 + 门面分层。 观测修改类事件 fail-open(吞错继续),安全拦截类事件 fail-closed(出错即拦);三层 API 门面按"调用时机的安全性"分配能力,危险操作只进命令上下文;stale 失效防僵尸引用。
四条可以带走的插件系统设计方法论:
| 方法 | Pi 的实现 | 你的项目里怎么用 |
|---|---|---|
| 事件总线暴露关键点 | 8 个介入点 emit 统一事件 | 别为每个定制点写专门 hook 函数;识别工作流关键节点,emit 带协议的事件,扩展性数量级更好 |
| throwing stubs + 延迟绑定 | 注册期抛错、bindCore 替换 | 插件加载早于核心就绪时,用抛错桩把"时机错误"变成即时可读的失败,用共享对象替换实现无感切换 |
| 按时机分配门面 | ExtensionAPI / Context / CommandContext | 给插件的不是权限分级,而是"什么时机能安全做什么"的分级;读自由、写收口 |
| 按后果选择错误语义 | 观测 fail-open,拦截 fail-closed | 错误隔离不是无脑 try/catch;先问"这个处理器失效的最坏后果是什么",再决定吞错还是阻断 |
最后值得体会的是第 1 章那张"减法哲学"表格和本章的关系:Pi 敢于不做 MCP、不做子 Agent、不做权限弹窗、不做计划模式,前提是扩展系统足够强——官方 README 列出的扩展能力清单里,"Sub-agents and plan mode"、"Permission gates"、"MCP server integration" 赫然在列,甚至还有 "Make pi look like Claude Code"。减法哲学不是少做功能,而是把功能的决定权移交给用户。扩展系统就是那个移交仪式。
扩展系统让 Pi 有了"不改源码加能力"的机制。但一个新问题随之而来——怎么测试这些能力?
你写了个扩展,注册了 tool_call 处理器返回 { block: true },怎么验证它真的拦住了?更进一步,Pi 自己的 Agent Loop、工具管道、压缩算法,测试要调真的 LLM 吗?LLM 输出不确定、调用要花钱、还依赖网络——传统"给定输入断言输出"的测试方法,在 Agent 系统面前处处碰壁。
Pi 的仓库里有 300+ 个测试文件在回答这个问题。下一章测试模式,我们看它怎么把一个"不确定的系统"测出确定性。
| 分组 | 事件 | 模式 | 返回值协议 |
|---|---|---|---|
| 启动/资源 | project_trust | 否决 | {trusted: "yes"/"no"/"undecided", remember?},首个 yes/no 者胜 |
| 启动/资源 | resources_discover | 链式(聚合) | {skillPaths?, promptPaths?, themePaths?} |
| 会话 | session_start / session_info_changed / session_compact / session_shutdown / session_tree | 通知 | — |
| 会话 | session_before_switch / session_before_fork | 否决 | {cancel?: true} |
| 会话 | session_before_compact | 否决/替换 | {cancel?} 或 {compaction}(自定义摘要) |
| 会话 | session_before_tree | 否决/替换 | {cancel?} 或 {summary} |
| Agent | agent_start / agent_end / agent_settled / turn_start / turn_end | 通知 | — |
| Agent | before_agent_start | 链式 | {message?, systemPrompt?} |
| Agent | context | 链式 | {messages?}(深拷贝,安全修改) |
| Provider | before_provider_headers | 就地修改 | mutate event.headers,null 删除 |
| Provider | before_provider_request | 链式 | 返回值替换 payload |
| Provider | after_provider_response | 通知 | — |
| 消息 | message_start / message_update | 通知 | — |
| 消息 | message_end | 链式 | {message?}(role 必须一致) |
| 工具 | tool_execution_start/update/end | 通知 | — |
| 工具 | tool_call | 否决 | {block?, reason?};event.input 可就地改参 |
| 工具 | tool_result | 链式 | {content?, details?, isError?, usage?} 部分补丁 |
| 模型 | model_select / thinking_level_select | 通知 | — |
| 输入 | input | 链式/接管 | {action: "continue"/"transform"/"handled"} |
| 输入 | user_bash | 接管 | {operations?} 或 {result} |
本章关键源码索引(Pi v0.81.1):
- packages/coding-agent/src/core/extensions/types.ts:1023-1048 — 34 种 ExtensionEvent 联合类型;:1177-1218 — on() 的 30 个重载(事件↔返回值对照表);:305-378 — ExtensionContext / ExtensionCommandContext 门面定义
- packages/coding-agent/src/core/extensions/runner.ts:784-816 — 通用 emit()(错误隔离);:915-936 — emitToolCall(一票否决,fail-closed);:860-913 — emitToolResult(链式修改);:311-408 — bindCore;:539-546 — stale 失效
- packages/coding-agent/src/core/extensions/loader.ts:170-223 — createExtensionRuntime(throwing stubs);:230-393 — createExtensionAPI;:594-624 — 目录扩展入口解析;:403-428 — jiti 双模式加载;:673-721 — 三路径发现
- packages/coding-agent/src/core/extensions/wrapper.ts — 扩展工具包装(全 45 行)
- packages/coding-agent/src/core/agent-session.ts:466-516 — beforeToolCall/afterToolCall 与扩展总线的接线;:2603-2626 — /reload 流程
- examples/extensions/permission-gate.ts、confirm-destructive.ts — 本章两个真实示例的完整源码
版本说明:本章基于 Pi v0.81.1 源码撰写(前 10 章基于 v0.80.2)。扩展事件数量随版本演进(v0.80.2 约 28 种 → v0.81.1 为 34 种),机制设计保持稳定。