7.1 Extension 系统
本页分析版本earendil-works/pi@16787ad2026-09-21本章解决什么问题:第五、六部分把 Pi 自己的主链路走完了。但 Pi 有一半的能力不在主链路上——它们来自用户放在磁盘上的几个
.ts文件。本章回答四个问题:扩展(Extension)到底是什么形态的东西?Pi 在哪里、用什么方式找到并加载它?加载后它能碰到 Pi 的哪些部位?它抛错的时候谁来兜底? 前置知识:1.4 模块系统(默认导出)、2.5 异步编程、2.7 事件、回调与取消(EventEmitter)、5.3 一次 Tool Call 的完整循环、6.4 工具系统。 学习目标:① 说出扩展文件必须导出什么、不满足会得到什么错误;② 复述扩展被发现和加载的完整链路,并解释 jiti 在其中的位置;③ 分清pi.on(...)与pi.events是两套毫不相干的事件系统;④ 说清「注册期」与「运行期」的分界,以及为什么工厂函数里不能调用pi.sendMessage();⑤ 讲清扩展 handler 抛错时哪些情况被吞掉、哪一种会阻断工具执行。
建立直觉:扩展是一个只被调用一次的函数
先把结论摆出来,后面所有细节都挂在它上面:
一个扩展就是一个默认导出了函数的 TypeScript 文件。Pi 在启动时 import 这个文件,拿到那个函数,把一个叫 pi 的大对象作为唯一参数传进去,调用它一次,然后就再也不调用了。
这个函数体里发生的一切都是「登记」:登记我要监听哪些事件、我提供哪个工具、我占用哪个斜杠命令。函数返回之后,扩展这个「文件」的使命就结束了——真正在运行的,是它留下的那些闭包(回调函数)。
ExtensionFactory。它跟 class 的构造函数扮演同样的角色:给你一次机会把自己接进宿主,之后宿主只跟你登记过的回调打交道。 ExtensionFactory// packages/coding-agent/src/core/extensions/types.ts:1715-1716
/** Extension factory function type. Supports both sync and async initialization. */
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;没有它会怎样:Pi 就只能靠命令行参数和配置文件被定制。想在每次写文件前拦一道、想让 agent 说海盗腔、想接一个公司内部的模型网关,都得改 Pi 自己的源码并重新构建。扩展机制把这些需求从「改源码」降级成「往目录里丢一个文件」。
最小示例:十行的 hello 扩展
这是一个可以直接用的完整扩展,不需要任何 API Key:
// /tmp/hello.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function helloExtension(pi: ExtensionAPI) {
pi.registerCommand("hello", {
description: "Say hello from a book extension",
handler: async (_args, ctx) => {
ctx.ui.notify("Hello from /tmp/hello.ts!", "info");
},
});
}三个要点:
export default是硬性要求。默认导出如果不是函数,Pi 会明确报错Extension does not export a valid factory function(源码事实,判定在packages/coding-agent/src/core/extensions/loader.ts:503-505,报错文案在loader.ts:570)。测试packages/coding-agent/test/extensions-discovery.test.ts:414专门覆盖了「只有具名导出、没有默认导出」这一情况。import type只是为了类型提示。@earendil-works/pi-coding-agent这个包名在扩展被加载时由 Pi 自己解析,不需要你在扩展旁边跑npm install(下一节讲 jiti 时会解释)。handler的第二个参数ctx与工厂参数pi是两个不同的对象:pi用来注册,ctx用来在运行时访问会话与界面。混淆这两个是最常见的入门错误。
pi 上所有「动作类」方法都是一个只会抛错的桩函数,错误文案是 Extension runtime not initialized. Action methods cannot be called during extension loading.(源码事实,loader.ts:154-156)。正确做法是把动作放进事件 handler 或命令 handler 里——那时候运行时已经就位。唯一的例外是 registerTool:它在加载期就合法(loader.ts:175-176 把 refreshTools 预置成空函数)。 回到 Pi 源码:扩展从磁盘到内存
第一步:在哪里找
自动发现有两个固定位置,外加显式配置的第三类。顺序写死在 discoverAndLoadExtensions 里(源码事实,packages/coding-agent/src/core/extensions/loader.ts:749-794):项目本地在前、全局其次、显式路径最后,中间用一个 Set 去重(loader.ts:758-768)。
- 项目本地:
<cwd>/.pi/extensions/(loader.ts:770-772)。.pi这个目录名来自CONFIG_DIR_NAME(packages/coding-agent/src/config.ts:504,可被package.json的piConfig.configDir改写)。 - 全局:
~/.pi/agent/extensions/(loader.ts:774-776)。 - 显式路径:设置文件里配置的目录,或命令行
--extension/-e(packages/coding-agent/src/cli/args.ts:166-168;帮助文本见args.ts:302,也可在真实采集的research/cli-captures/pi-help.txt:41里看到同一行)。
单个目录里的发现规则只有三条,而且只下探一层:
discoverExtensionsInDir1. Direct files: `extensions/*.ts` or `*.js` → load
2. Subdirectory with index: `extensions/*/index.ts` or `index.js` → load
3. Subdirectory with package.json: `extensions/*/package.json` with "pi" field → load what it declares
No recursion beyond one level. Complex packages must use package.json manifest.「不递归超过一层」有测试兜底:packages/coding-agent/test/extensions-discovery.test.ts:264 的用例 does not recurse beyond one level 构造了 container/nested/index.ts,断言它不会被发现。
这里有一处文档与实现的轻微不一致(分析解释):官方文档 packages/coding-agent/docs/extensions.md:115-120 的位置表只列了 *.ts 与 */index.ts 两种形态,.js 与 package.json manifest 要到该文档后文(extensions.md:226 起的 Extension Styles 一节)才补上。以源码为准。
项目本地扩展还多一道闸门:只有当项目被标记为「信任」时才会被收进来(源码事实,packages/coding-agent/src/core/package-manager.ts:2417-2425 的 if (projectTrusted) 分支)。这就是你第一次在一个新目录里运行 pi 会看到「Trust project folder?」的原因——真实采集见 research/cli-captures/pi-tui-startup.txt。信任状态还没定下来时,Pi 会先按「不信任」跑一遍加载(packages/coding-agent/src/core/resource-loader.ts:379-385),只装用户级和 CLI 传入的扩展。
discoverAndLoadExtensions 这个函数从 packages/coding-agent/src/index.ts:190 导出,供 SDK(软件开发工具包)使用。真实启动路径是 ResourceLoader 委托 PackageManager 算出路径清单,再调 loadExtensionsCached(resource-loader.ts:549-559);扩展目录由 package-manager.ts:2385-2396 给出,目录内规则与上面等价。两条路径的发现规则一致,所以本章用更易读的 discoverAndLoadExtensions 讲解规则,用 ResourceLoader 讲解真实时序。 第二步:怎么加载一个 .ts 文件
Node.js 本身不能直接 import 一个 .ts 文件。Pi 用 jiti 解决这件事:
createJitiImpl// packages/coding-agent/src/core/extensions/loader.ts:487-501
const createJitiImpl = await getCreateJiti();
// Compiled binaries and the bundled Node distribution use embedded modules.
// Source TypeScript reuses host modules and root tsconfig paths. Unbundled
// Node builds use dist aliases and do not need the bundled virtual modules.
const resolutionOptions = usesEmbeddedModules
? { virtualModules: await getVirtualModules(), tryNative: false }
: isTypeScriptSourceRuntime
? { virtualModules: await getVirtualModules(), tsconfigPaths: true }
: { alias: getAliases() };
const jiti = createJitiImpl(import.meta.url, {
moduleCache: false,
...resolutionOptions,
});
const module = await jiti.import(extensionPath, { default: true });这十几行里藏着一个真实的工程难题。扩展写的是 import ... from "@earendil-works/pi-coding-agent",但用户的 ~/.pi/agent/extensions/ 目录里根本没有 node_modules——这个包名要怎么解析?三种运行形态答案不同:
| 运行形态 | 策略 | 说明 |
|---|---|---|
| 内嵌模块的发行形态:Bun 编译的单二进制、Node 单可执行文件(SEA)、打包后的 Node 发行版 | virtualModules | 框架包已经被打进产物,用一张虚拟模块表把包名映射到内存里的模块(判定见 loader.ts:37-41 的 usesEmbeddedModules,表在 packages/coding-agent/src/core/extensions/virtual-modules.ts:14-38) |
TypeScript 源码直跑(./pi-test.sh) | virtualModules + tsconfigPaths | 复用宿主已解析好的模块,并让 jiti 认识根 tsconfig 的 path 映射 |
| 未打包的 Node 构建 | alias | 把包名重定向到 dist/ 下的真实文件(getAliases(),loader.ts:65-121) |
还有一个容易忽略的细节:jiti 本身和那张虚拟模块表都是延迟加载的。getCreateJiti()(loader.ts:45-50)和 getVirtualModules()(loader.ts:54-57)用动态 import() 按需取模块,并把 Promise 缓存起来——只有真的要从磁盘加载一个扩展时才付这笔成本;不从磁盘加载扩展的调用方(例如只传内联工厂函数的 SDK 用户)根本不会载入 jiti。内嵌形态走 jiti/static 入口(jiti-static-loader.ts),普通 Node 走 jiti(jiti-loader.ts),两个文件各只有一行导出,注释说明了原因:打包器需要静态入口才能把 Babel 转换器一起嵌进去。
官方文档说明「Extensions are loaded via jiti, so TypeScript works without compilation」(来源文件:packages/coding-agent/docs/extensions.md:179),与这段实现一致。
moduleCache: false 关掉了 jiti 自己的缓存,Pi 用自己的一层缓存代替(loader.ts:125-147),缓存键是解析后的绝对路径,缓存令牌绑定 cwd 与一个自增的 generation。/reload 时 clearExtensionCache() 被调用(resource-loader.ts:391-393),generation 自增让旧令牌全部失效。注意缓存的是模块(即工厂函数),工厂本身每次仍会重新执行——回归测试 packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts:69 就是钉这条语义的。
第三步:完整加载链路
loadExtension// packages/coding-agent/src/core/extensions/loader.ts:564-579(有省略)
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath, cacheToken);
// …(省略:568 行,记录耗时)
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime);
return { extension, error: null };
} catch (err) {
// …(省略:577-578 行,把异常包成 "Failed to load extension: <message>")
}真正调用你的代码的是 initializeExtension:
initializeExtension// packages/coding-agent/src/core/extensions/loader.ts:544-552
const extension = createExtension(extensionPath, resolvedPath);
const load = createExtensionAPI(extension, runtime, cwd, eventBus);
try {
await factory(load.api);
load.commit();
} catch (error) {
load.discard();
throw error;
}createExtension(loader.ts:515-534)造出来的是一个全是空 Map 的容器:handlers、tools、commands、flags、shortcuts、messageRenderers、entryRenderers(类型定义见 types.ts:1893-1906)。工厂函数每调一次 pi.registerXxx,就往其中一个 Map 里塞一条。
commit / discard 这一对让加载变成了「要么全生效、要么不留痕迹」(源码事实,loader.ts:450-468):工厂执行期间,registerFlag 的默认值、registerProvider / unregisterProvider 这类会改动共享 runtime 的请求先暂存在这个扩展自己的待办列表里;工厂成功返回,commit 才把它们一次性写进共享 runtime。工厂中途抛错,discard 丢掉这些暂存项、退订加载期间在 pi.events 上登记的监听,并把这个 pi 标成失效——之后再调用它会得到 Extension "…" failed to load and its API is no longer active.(loader.ts:238-243)。所以一个写了一半就崩掉的扩展,不会在共享状态里留下半截 provider 注册或 flag 默认值。
图 7.1-1 扩展从磁盘到可用的完整链路
从上到下是一次启动。上半段(B→K)是「注册期」,下半段(L→N)是「运行期」的开端。请特别关注 G 到 K 这几步:每个扩展都是独立地走一遍,任何一个失败只产生一条 error 记录,不影响其它扩展。分支 X 是失败出口,两条进入它的边分别对应「默认导出不是函数」和「工厂执行中抛错」。图中每个节点都标注了真实源码位置。
图里 L 之后的三步是本章最容易被忽略的部分:工厂执行完的时候,扩展还处于「半残」状态。下一节解释为什么。
下面把启动、一次 prompt 和 /reload 分开播放。先在“启动”里故意停在 factory:注册工具是合法的,调用 sendMessage 却会得到源码里的真实报错;继续到 bindCore 后动作方法才接上宿主实现。注意界面里的 bind 是宿主装配阶段,不是扩展能用 pi.on(...) 订阅的事件。
Extension lifecycle
扩展从加载到运行,哪些是接线,哪些才是事件?
这里不会自动播放。每点一次“下一步”,runtime 只向前推进一个阶段。
从扩展文件到资源发现,严格走一遍现役初始化顺序。
module import
await loadExtensionModule(resolvedPath)loader 导入模块并取得默认导出的 factory;此时还没有 ExtensionRunner。
此刻状态:模块已求值,factory 已取得。
启动预设已就绪。使用“下一步”逐步查看。
ExtensionAPI:pi 上到底有什么
ExtensionAPI按用途分成六组(全部来自 types.ts:1349-1624,行号为该能力在文件中的位置):
| 组别 | 方法 | 位置 |
|---|---|---|
| 订阅生命周期事件 | on(event, handler),39 个重载,返回取消订阅函数 | types.ts:1354-1419 |
| 注册 | registerTool / registerCommand / registerShortcut / registerFlag / registerMessageRenderer / registerMarkdownTransformer / registerEntryRenderer | types.ts:1425-1476 |
| 注入消息 | sendMessage / sendUserMessage / appendEntry | types.ts:1482-1499 |
| 会话与执行 | setSessionName / getSessionName / setLabel / exec / getActiveTools / getAllTools / setActiveTools / getCommands | types.ts:1505-1527 |
| 模型 | setModel / getThinkingLevel / setThinkingLevel / registerProvider / unregisterProvider | types.ts:1537-1546、1604-1620 |
| 扩展间通信 | events: EventBus | types.ts:1622-1623 |
on() 的返回值是一个函数,调用它就把刚登记的 handler 摘掉(实现见 loader.ts:256-271:它在 handler 列表里按引用找到自己并 splice 掉)。官方 CHANGELOG 对它的时序补了一句(来源文件:packages/coding-agent/CHANGELOG.md:88):在一次分发进行中增删 handler,只影响之后的分发、不影响当前这次——源码里对应的是 runner 每次分发前先用 snapshotEventHandlers 复制一份 handler 列表(runner.ts:265-267)。
注意 pi 上没有 UI(用户界面)方法。界面能力挂在 ctx.ui 上(ExtensionUIContext,types.ts:137-288),只有事件 handler 和命令 handler 能拿到 ctx。这个划分不是随意的:界面在工厂执行时还不存在。
注册期与运行期
createExtensionAPI(loader.ts:228-469)把 pi 的方法分成泾渭分明的两类:
- 注册类(
on、registerTool、registerCommand…):直接往extension对象的 Map 里写,loader.ts:256-341。加载期立即可用。 - 动作类(
sendMessage、setModel、exec…):全部转发给一个共享的ExtensionRuntime对象,loader.ts:350-419。
而这个 runtime 在扩展加载时是空的:
createExtensionRuntime// packages/coding-agent/src/core/extensions/loader.ts:153-221(有省略)
export function createExtensionRuntime(): ExtensionRuntime {
const notInitialized = () => {
throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
};
// …(省略:157-163 行的 staleMessage、eventBusUnsubscribers 与 assertActive)
const runtime: ExtensionRuntime = {
sendMessage: notInitialized,
sendUserMessage: notInitialized,
// …(省略:168-174 行,其余动作方法同样指向 notInitialized)
// registerTool() is valid during extension load; refresh is only needed post-bind.
refreshTools: () => {},
// …(省略:177-217 行,其余字段、invalidate 与 provider 排队逻辑)
};
return runtime;
}真正的实现要等 ExtensionRunner.bindCore(packages/coding-agent/src/core/extensions/runner.ts:402-501)把 AgentSession._bindExtensionCore(packages/coding-agent/src/core/agent-session.ts:3020-3046)提供的函数逐个拷进 runtime。因为所有扩展共享同一个 runtime 对象,这一次拷贝就同时「点亮」了全部扩展。
registerProvider 是这套两阶段设计里最精巧的一处:工厂期调用它不会抛错。它先作为这个扩展的暂存项等 commit(loader.ts:421-429 的 applyRuntimeChange),commit 时进入共享 runtime 的 pendingProviderRegistrations 队列(loader.ts:206-208),bindCore 里再统一 flush(runner.ts:442-476)。官方文档说明「async initialization completes before session_start…before provider registrations queued via pi.registerProvider() are flushed」(来源文件:packages/coding-agent/docs/extensions.md:181)——这正是 await factory(api) 与 bindCore 的先后顺序。这条链路第 7.4 自定义 Provider 会展开。
反过来,会话被替换或 /reload 之后,旧的 pi 与 ctx 会被整体失效:AgentSession.reload() 先发出 session_shutdown,随即调用 oldRunner.invalidate()(agent-session.ts:3291-3295);runner.invalidate 再调 runtime.invalidate 写入一条 staleMessage(runner.ts:679-686、loader.ts:185-192),此后每个 API 调用前的 assertActive()(loader.ts:238-243,内部调用 runtime.assertActive())都会抛错。一种看法是,这比让旧对象静默地操作已经关闭的会话要安全得多;代价是扩展作者必须理解「不要跨会话缓存 ctx」这条规矩,而这条规矩只能靠报错文案来传达。
两套事件系统,别搞混
Pi 里有两个都叫「事件」的东西,机制完全不同。
A. 生命周期事件:pi.on(...)
这套不是 EventEmitter。宿主在特定时刻构造一个事件对象,交给 ExtensionRunner 顺序分发;handler 的返回值往往会被采纳。事件全集是一个可辨识联合(Discriminated Union),共 30 个成员(types.ts:1170-1200,其中 SessionEvent 自己又是 10 个会话事件的子联合,types.ts:675-685),对应 pi.on 上的 39 个重载。
分发核心只有三十行:
RunnerEmitResult// packages/coding-agent/src/core/extensions/runner.ts:988-1016(有省略)
async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> {
const ctx = this.createContext();
let result: SessionBeforeEventResult | undefined;
for (const { ext, handlers } of snapshotEventHandlers(this.extensions, event.type)) {
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (this.isSessionBeforeEvent(event) && handlerResult) {
result = handlerResult as SessionBeforeEventResult;
if (result.cancel) return result as RunnerEmitResult<TEvent>;
}
} catch (err) {
// …(省略:1004-1011 行,包成 ExtensionError 交给 emitError 上报)
}
}
}
return result as RunnerEmitResult<TEvent>;
}snapshotEventHandlers(runner.ts:265-267)在分发开始时把每个扩展的 handler 列表各复制一份,所以 handler 在执行中调用 pi.on() 或取消订阅,只影响下一次分发。
并不是所有事件都走这个通用 emit:类型 RunnerEmitEvent(runner.ts:172-189)把 tool_call、context、turn_end、agent_before_settle 等 15 种事件排除在外,它们各有专门的 emit* 方法。按 handler 返回值的用法,可以把事件分成五类(分析解释,分类依据是 runner 里各个 emit* 方法的实现):
| 类别 | 代表事件 | 返回值语义 | 实现 |
|---|---|---|---|
| 观察型 | agent_start、turn_start、tool_execution_*、session_compact_failed、ui_prompt_start/end | 忽略 | runner.ts:988 通用 emit |
| 链式改写型 | context、context_with_system、message_end、tool_result、before_provider_request | 后一个 handler 收到前一个的结果 | runner.ts:1190(前两者)/ 1043 / 1082 / 1253 |
| 短路型 | tool_call 的 block、input 的 handled、user_bash 的结果、session_before_* 的 cancel | 第一个给出决定的 handler 获胜 | runner.ts:1134 / 1412 / 1154 / 988 |
| 聚合型 | resources_discover、before_agent_start | 各 handler 的产出被合并 | runner.ts:1366 / 1312 |
| 边界型 | turn_end、agent_before_settle | 可追加要持久化的会话条目、可要求「再发一次请求」 | runner.ts:928 emitBoundary |
最后一类值得多说两句,因为 turn_end 以前是纯观察型事件,现在变成了一个可以「动手」的边界。宿主把一轮对话定稿(助手消息和全部工具结果都已写进会话)之后调用 emitBoundary,每个 handler 收到的事件里带着三样东西:到目前为止累积的 entries(待写入会话的条目草稿)、continue 标志、以及按这些草稿预演出来的下一次请求上下文 context(types.ts:805-810)。handler 返回 { entries, continue } 就能替换草稿列表、设定是否继续;下一个 handler 看到的是替换后的值(runner.ts:938-971)。草稿只能是四种结构化条目——自定义条目、自定义消息、上下文编辑、压缩(types.ts:762-795)。全部 handler 跑完后,AgentSession 按顺序把草稿写进会话(agent-session.ts:667),continue: true 则经 agent-core 的 finishTurn 钩子保证再发起一次 provider 请求(agent-session.ts:675-685)。官方 CHANGELOG 给的典型写法是返回 { entries: [...event.entries, draft], continue: true }(来源文件:packages/coding-agent/CHANGELOG.md:22)。agent_before_settle 是同一套机制,时机换成「整个 run 即将结束、准备进入空闲」之前。
事件从哪儿发出来?主要有三条注入点(源码事实):
- Agent 事件桥接:
agent.subscribe(this._handleAgentEvent)(agent-session.ts:434)→_handleAgentEvent(agent-session.ts:894)→_emitExtensionEvent(定义在agent-session.ts:1062)把 agent 层事件翻译成扩展事件。 - 工具钩子:
_installAgentToolHooks把 agent-core 的两个钩子接到 runner 上。 - Provider 链:
packages/coding-agent/src/core/sdk.ts:311-394把 header 变换(before_provider_headers)、payload 变换(before_provider_request)、响应回调(after_provider_response)、上下文变换(context与context_with_system)四件事接出来。 - 边界钩子:
_installAgentBoundaryHooks(agent-session.ts:675-685)把 agent-core 的finishTurn接到turn_end边界上。
图 7.1-2 tool_call 事件的分发与短路
从上到下是一次工具调用被拦截的过程。请关注两点:其一,扩展 A 与扩展 B 是顺序执行的,顺序由加载顺序决定;其二,扩展 B 返回 block 之后 runner 立刻返回,扩展 C 及之后的 handler 根本不会被调用。最后一步发生在 agent-core:收到 block 的调用不会执行,而是直接变成一条 isError 的工具结果,reason 就是它的正文(agent-loop.ts:739-748)。参与者对应的源码分别是 packages/agent/src/agent-loop.ts、agent-session.ts:530、runner.ts:1134。
工具拦截的挂接点写得很直白:
_installAgentToolHooksemitToolCall 是全部 emit* 方法里唯一没有 try/catch 的(runner.ts:1134-1152)。官方文档说明「tool_call errors block the tool (fail-safe)」(来源文件:packages/coding-agent/docs/extensions.md:3003)。也就是说:守门扩展自己崩了,Pi 选择「宁可不执行」而不是「当作放行」。据此推断(尚未在源码中直接证实),这是刻意的失败安全设计——该方法上没有注释说明意图,判断依据是文档与行为的一致性。
拦截结果除了 block 与 reason,还可以带一个 terminate: true(ToolCallEventResult,types.ts:1217-1226)。它只对被拦下的调用有意义:agent-core 把它原样抄到那条错误工具结果上(packages/agent/src/agent-loop.ts:739-743),而一批工具调用里每一条定稿结果都带 terminate 时,循环就不再把工具结果送回模型、直接结束(agent-loop.ts:684-686 的 shouldTerminateToolBatch)。只要有一条没带,模型照常收到全部结果并继续。
同样「宁可不执行」的还有 user_bash(用户在输入框里用 ! 执行的命令):emitUserBash 虽然有 try/catch,但在上报之后会把异常重新抛出(runner.ts:1168-1177),返回值不合法也按抛错处理,命令因此被中止,既不会交给后面的 handler,也不会退回本地执行。
B. 扩展间总线:pi.events
这一套跟上面毫无关系。它就是一个 Node EventEmitter 的薄包装,全文 33 行:
createEventBus// packages/coding-agent/src/core/event-bus.ts:18-28
on: (channel, handler) => {
const safeHandler = async (data: unknown) => {
try {
await handler(data);
} catch (err) {
console.error(`Event handler error (${channel}):`, err);
}
};
emitter.on(channel, safeHandler);
return () => emitter.off(channel, safeHandler);
},总线实例由 ResourceLoader 创建(resource-loader.ts:258),一路传进 loadExtensionsCached 再传进 createExtensionAPI,最后挂到 pi.events 上(loader.ts:436-447)。同一次加载里的所有扩展拿到的是同一个实例——这就是它能当作扩展间通信管道的原因。官方文档把它描述为 "Shared event bus for communication between extensions"(来源文件:packages/coding-agent/docs/extensions.md:1834)。
注意 pi.events.on 并不是把总线的 on 原样暴露出去:它先调总线的 on,再把返回的退订函数交给 runtime.trackEventBusSubscription 登记(loader.ts:441-446、loader.ts:193-203)。这份登记表有两个用途:扩展工厂中途抛错时,discard 会退订它在加载期登记的监听;会话被替换或 /reload 时,runtime.invalidate 会把这个 runtime 名下的所有总线监听一次性退订(loader.ts:190-191)。所以旧扩展实例留下的总线 handler 不会在 reload 之后继续收到消息。
图 7.1-3 两套事件系统的形态对比
左右两块互不相通。左边:事件由宿主发起,事件名来自一个固定的联合类型,handler 的返回值会影响宿主行为。右边:事件由扩展发起,频道名任意,返回值无人关心,异常只被 console.error 记录。读者要关注的关键差别是「谁发起」和「返回值有没有人看」。左边对应 runner.ts,右边对应 event-bus.ts 全文。
/reload 与总线的关系到这里就闭合了:总线实例本身(由 ResourceLoader 持有)跨 reload 复用,EventBusController.clear() 并不会被调用;被清理的是每个旧扩展实例登记的监听。这一点有回归测试兜底:packages/coding-agent/test/suite/regressions/7193-event-bus-lifecycle.test.ts:16 的用例 removes extension-owned event-bus listeners on reload and dispose 断言每次 reload 之后扩展的监听始终只剩一份、dispose() 之后归零,而宿主自己挂在总线上的监听不受影响。
错误隔离:扩展抛错会怎样
把前面的碎片拼起来,一共四种情况(四条都是源码事实):
- 工厂函数抛错(或默认导出根本不是函数):
loadExtension的 try/catch 把它变成LoadExtensionsResult.errors里的一条(loader.ts:576-579),加载循环继续跑完剩下的扩展(loader.ts:622-625:errors.push(...)之后continue)。 - 普通事件 handler 抛错:被
emit的 try/catch 捕获,包成ExtensionError交给emitError上报,循环继续执行后面的 handler(runner.ts:1003-1012)。turn_end/agent_before_settle这类边界事件也一样(runner.ts:950-957);此外 runner 还会校验 handler 返回的条目草稿,校验不过就上报错误并把这次边界的产出整体作废(runner.ts:959-976)。测试packages/coding-agent/test/extensions-runner.test.ts:602(calls error listeners when handler throws)覆盖了这条路径。 tool_callhandler 抛错:不被捕获,一路传到_installAgentToolHooks被重新抛出,阻断本次工具执行(agent-session.ts:543-548)。- 命令 handler 抛错:被
_tryExecuteExtensionCommand捕获上报,并且仍然返回true(agent-session.ts:1780-1788)——也就是说这条输入被认为「已处理」,不会退回去当成给模型的提示词。
官方文档把第 2 条概括为 "Extension errors are logged, agent continues"(来源文件:packages/coding-agent/docs/extensions.md:3002)。
「加载循环继续」是 loader 这一层的事实,不代表进程能活下来。main() 把 resourceLoader.getExtensions().errors 里的每一条都翻译成 error 级启动诊断(源码事实,packages/coding-agent/src/main.ts:786-789),随后只要诊断里出现任何一条 error,就打印一行提示并 process.exit(1)(main.ts:897-906)。
换句话说:一个扩展写坏,整个 pi 起不来——无论它是 -e 显式传入的还是从目录里自动发现的,也无论同批次里其它扩展是否完好。本章实践任务第 5 步会亲手复现这一点(作者已实测,见其中的真实输出与退出码)。
那 interactive-mode.ts:1875 读取同一份 errors 渲染成面板诊断的路径什么时候走得到?从源码结构看,只可能发生在启动之后——例如会话已经跑起来、用户执行 /reload 重新加载资源时;启动期只要 errors 非空就已经退出了。
走读官方示例:pirate.ts
packages/coding-agent/examples/extensions/ 下有 78 个官方示例条目(含少量子目录形态的复杂示例)。选 pirate.ts 走读,是因为它用 33 行同时演示了三件事:命令注册、闭包状态、系统提示词(System Prompt)改写。
pirateExtension// packages/coding-agent/examples/extensions/pirate.ts:15-46(有省略)
export default function pirateExtension(pi: ExtensionAPI) {
let pirateMode = false; // ① 闭包状态
pi.registerCommand("pirate", { // ② 注册斜杠命令
description: "Toggle pirate mode (agent speaks like a pirate)",
handler: async (_args, ctx) => {
pirateMode = !pirateMode;
ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info");
},
});
pi.on("before_agent_start", async (event) => { // ③ 每轮开始前介入
if (pirateMode) {
return {
systemPrompt: event.systemPrompt + `\n\n…(省略:35-42 行的海盗腔指令正文)`,
};
}
return undefined; // ④ 不干预就返回 undefined
});
}逐点说明:
① 状态放在闭包里。工厂只跑一次,pirateMode 这个变量的生命周期就等于「这次加载的这个扩展实例」。命令 handler 和事件 handler 引用的是同一个变量——这是扩展保存状态最简单的方式。它的边界是:进程退出或 /reload 就丢。想让状态跟着会话走,官方示例给了两条路(源码事实):一是 pi.appendEntry(types.ts:1498-1499),把一条不进入 LLM 上下文的自定义条目写进会话,examples/extensions/entry-renderer.ts:35 是最短的用法;二是 todo.ts 的做法——状态存进工具结果的 details 里,在 session_start / session_tree 时遍历 ctx.sessionManager.getBranch() 重建(examples/extensions/todo.ts:114-133),好处是切换分支时状态自动回到那一点的正确值(该文件开头 todo.ts:8-10 的注释就是这么解释的)。
② /pirate 命令怎么被路由到这里。用户输入以 / 开头时,AgentSession.prompt 会最先尝试扩展命令(agent-session.ts:1616-1625)→ _tryExecuteExtensionCommand(agent-session.ts:1766)→ runner.getCommand(name)(runner.ts:788)→ 用 createCommandContext() 造出命令专用的 ctx 再执行。命中之后直接 return:input 事件、skill 展开、模板展开、发给模型,一律不发生。内置命令则完全是另一套——BUILTIN_SLASH_COMMANDS(packages/coding-agent/src/core/slash-commands.ts:19-44)只是一份 24 条的数据清单,实现在交互模式里。多个扩展注册同名命令时,Pi 按注册顺序生成 name:1、name:2 消歧(runner.ts:739-773,测试见 extensions-runner.test.ts:510)。
③ before_agent_start 是聚合型事件。返回 { systemPrompt } 会整段替换本次运行的系统提示词(源码里是把它记成 forceSystemPrompt);多个扩展都返回时,emitBeforeAgentStart(runner.ts:1312-1364)把它们链起来——event.systemPrompt 是一个 getter,每次读取都按当前的提示词选项现渲染,所以后一个扩展读到的是前一个改过的值。除了整段替换,handler 还可以直接修改 event.systemPromptOptions 里的结构化片段(types.ts:737-747);官方文档更推荐这种方式,因为 Pi 会只把变化的片段补发给模型,对支持对话中途插入系统消息的模型能保住已缓存的前缀(来源文件:packages/coding-agent/docs/extensions.md:570)。测试 extensions-runner.test.ts:849 断言了 ctx.getSystemPrompt() 在链条中间也能读到最新值。
④ 返回 undefined 表示弃权。这是所有可改写事件的统一约定:不想干预就别返回东西。
想看更硬核的例子,protected-paths.ts(同目录,30 行)用 tool_call 事件在 write/edit 命中 .env、.git/、node_modules/ 时返回 { block: true, reason }——那正是图 7.1-2 里扩展 B 的角色。
实践任务
目标:不需要任何 API Key,亲手完成「写扩展 → 被 Pi 发现 → 出现在 [Extensions] 清单」的完整闭环,并观察加载失败时的错误形态。
前提:已按 4.1 的步骤把 Pi 源码放在 _sources/pi 并执行过 npm install。下面把 Pi 仓库根目录记作 $PI。
步骤 1:写扩展文件。把本章「最小示例」那十行存成 /tmp/hello.ts。
步骤 2:换一个干净目录启动(避免项目信任提示干扰):
mkdir -p /tmp/pi-hello-run
cd /tmp/pi-hello-run
$PI/pi-test.sh --no-env -e /tmp/hello.ts预期现象:进入终端界面(TUI)后,聊天区上方会出现一段启动清单。以下是作者真实运行的采集结果(在 200 列宽的 tmux 窗口里启动,用 tmux capture-pane -p 取回纯文本,未作改写):
pi v0.87.0
escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
Press ctrl+o to show full startup help and loaded resources.
Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.
[Skills]
find-skills, ssh-skill
[Extensions]
hello.ts
Warning: No models available. Use /login to log into a provider via OAuth or API key. See:三点说明:
· [Skills] 那两行来自作者本机 ~/.agents/skills/ 里已有的技能,与本任务无关,你的机器上很可能没有这一段;要对照的只有 [Extensions] 下面的 hello.ts。
· 版本号 pi v0.87.0 取决于你检出的 commit。
· 那条 Warning 是预期的——--no-env 清掉了所有 API Key。扩展的加载与模型是否可用完全无关,这正是本任务能在无 Key 环境下做的原因。
步骤 3:看完整路径。按 ctrl+o 展开启动信息,同一段会变成分组形式(同样为真实运行结果):
[Extensions]
path
/tmp/hello.tspath 是该扩展的来源分组。分组一共只有三种——project、user、path,按这个顺序排列(源码事实,src/modes/interactive/interactive-mode.ts:1546-1562 定义、:1579 排序)。user 对应 ~/.pi/agent/extensions/,project 对应 .pi/extensions/,命令行显式路径落到 path。
步骤 4:验证命令真的注册上了。在输入框逐字敲入 /hello,编辑框下方会浮出补全项。真实采集到的那一行是:
→ hello [t] Say hello from a book extension描述文字正是你在 registerCommand 里写的 description。前面那个 [t] 是来源作用域标记:user → u、project → p、其余(也就是 temporary)→ t(源码事实,src/modes/interactive/interactive-mode.ts:636;SourceScope 的三个取值见 src/core/source-info.ts:3,合成来源默认就是 temporary,source-info.ts:36)。-e 传进来的扩展正属于这一类。此时直接按 enter,聊天区会多出一行通知(真实采集):
Hello from /tmp/hello.ts!退出用 ctrl+c 两次(启动帮助里写的是 ctrl+c twice to exit)。
步骤 5:制造一次失败。把 /tmp/hello.ts 里的 export default function 改成 export function(去掉 default),重新执行步骤 2 的命令。
预期现象:不会进入终端界面。Pi 直接退出,终端上只留下三行(真实采集,第一行来自 pi-test.sh --no-env):
Running without API keys...
Error: Failed to load extension "/tmp/hello.ts": Extension does not export a valid factory function: /tmp/hello.ts
Hint: Start without extensions using "pi -ne".用 echo $?(fish 用 echo $status)可以看到退出码是 1。这就是上一节提示框讲的那条:坏扩展在启动期直接终止进程。作者另外验证过,把同一个坏文件放进自动发现目录(PI_CODING_AGENT_DIR=/tmp/pi-fake-agent,文件在 /tmp/pi-fake-agent/extensions/broken.ts),即便同时用 -e 传入一个完好的扩展,结果一样是退出码 1。把 default 改回去即可恢复。
如何判断成功:你能同时做到四件事——① 在清单里看到 hello.ts,并执行出那句通知;② 说出这行清单是由 interactive-mode.ts:1828 的 addLoadedSection("Extensions", …) 渲染的;③ 说出步骤 5 里 Extension does not export a valid factory function 这段文案来自 loader.ts:570,而判定发生在 loader.ts:503-505;④ 说出外层的 Failed to load extension "…" 与那句 Hint: 分别来自 main.ts:788 和 main.ts:903,退出发生在 main.ts:905。
常见错误:
· 忘了 -e,只把文件放在 /tmp 里——/tmp 不是发现目录,什么都不会发生。想走自动发现,要放进 ~/.pi/agent/extensions/ 或项目的 .pi/extensions/。
· 在有 .pi 目录的仓库里启动会先弹「Trust project folder?」,需要先做出选择;这就是 package-manager.ts:2417 那道信任闸门。真实采集的这个界面见 research/cli-captures/pi-tui-startup.txt。步骤 2 特意换到 /tmp/pi-hello-run 就是为了绕开它。
· 把 ctx.ui.notify 写到工厂函数体里会抛错——工厂期没有 ctx,界面也还没就绪。
· 如果本机还没下载过 fd(Pi 用来加速文件查找的辅助二进制),界面挂载后聊天区会多出一行状态 fd not found. Downloading...(源码事实,文案在 src/utils/tools-manager.ts:375,由 src/modes/interactive/interactive-mode.ts:1021-1024 在界面挂载后检查 fd 与 rg 并显示);网络不通时改显示 Warning: Failed to download fd: … 并继续启动(tools-manager.ts:394-397),不影响本任务。rg 缺失时同理。作者的机器上已缓存这两个二进制,所以上面的采集里没有这些行。
对应源码位置:packages/coding-agent/src/cli/args.ts:166(-e 解析)、src/core/extensions/loader.ts:479-510(jiti 加载与合法性判定)、loader.ts:536-580(单个扩展加载)、src/core/extensions/runner.ts:739-773(命令解析)、src/modes/interactive/interactive-mode.ts:1820-1829(清单渲染)、src/main.ts:786-789 与 :897-906(把加载错误升格为启动失败)。
本章小结
- 扩展的全部形态是
ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>(types.ts:1716)。默认导出不是函数就会被判为无效。 - 发现有三个来源:项目
.pi/extensions/(受信任门控)、全局~/.pi/agent/extensions/、显式路径(含-e)。目录内三条规则、只下探一层。 - 加载用 jiti,TypeScript 免编译;三种运行形态(Bun 二进制 / TS 源码 / 构建后 Node)用三套模块解析策略,解决「框架包在扩展旁边不存在」的问题。
- 完整链路:
ResourceLoader.reload→loadExtensionsCached→loadExtension→jiti.import→createExtensionAPI→await factory(pi)→new ExtensionRunner→bindCore→session_start。 - 注册期只能注册,动作方法是抛错的桩,
bindCore之后才点亮;registerProvider用排队的方式绕开了这条限制。 - 两套事件系统:
pi.on是宿主发起、顺序 await、返回值被采纳的生命周期事件;pi.events是 33 行的 EventEmitter 包装,只服务扩展之间的自定义频道。 - 错误隔离分四档:工厂抛错弃掉该扩展、普通 handler 抛错记录后继续、
tool_callhandler 抛错阻断工具、命令 handler 抛错吞掉但仍算已处理。但加载期的错误隔离只到 loader 为止:main.ts:786-789把它升格为 error 级诊断,main.ts:897-906直接process.exit(1)——一个坏扩展会让整个 pi 起不来。 - 关键术语:扩展(Extension)、工厂函数(Factory Function)、jiti、ExtensionAPI、ExtensionRuntime、ExtensionRunner、事件总线(Event Bus)、失败安全(fail-safe)。
- 关键源码索引:
packages/coding-agent/src/core/extensions/types.ts:1349-1624(ExtensionAPI)、:1170-1200(事件联合)、:1893-1906(Extension 容器)packages/coding-agent/src/core/extensions/loader.ts:153-221(runtime 桩)、:228-469(造 pi 与 commit/discard)、:479-510(jiti)、:536-580(加载)、:657-797(发现)packages/coding-agent/src/core/extensions/runner.ts:402-501(bindCore)、:928-977(边界分发)、:988-1017(通用分发)、:1134-1152(tool_call)packages/coding-agent/src/core/event-bus.ts:1-33(全文)packages/coding-agent/src/core/agent-session.ts:529-585(工具钩子)、:635-685(turn_end 边界)、:1062-1139(事件桥接)、:3020-3046(bindCore 注入)packages/coding-agent/src/main.ts:786-789、:844-849(加载错误 → 启动失败)- 测试:
test/extensions-discovery.test.ts、test/extensions-runner.test.ts、test/extensions-input-event.test.ts、test/suite/regressions/extension-factory-cache.test.ts
- 自测问题:
- 一个扩展的工厂函数里写了
pi.setModel(...),启动时会发生什么?错误信息由哪个函数产生? - 扩展 A 和扩展 B 都监听了
tool_call,A 返回undefined、B 返回{ block: true },C 也监听了同一事件。C 的 handler 会被调用吗?为什么? pi.on("session_start", …)与pi.events.on("session_start", …)有什么关系?- 想让扩展的状态在
/reload后仍然存在,闭包变量为什么不够用? loadExtensionsInternal在某个扩展加载失败时会continue跑完剩下的——为什么这不意味着「坏扩展只是被跳过、Pi 照常启动」?请指出把它变成启动失败的那两处代码。
- 一个扩展的工厂函数里写了
- 下一章:7.2 Skill 系统——技能(Skill)是另一条定制路径,它不写代码、只写 Markdown,与扩展在斜杠命令空间里共存。
- 本章尚未展开的内容:
ExtensionUIContext的完整能力面(对话框、widget、自定义编辑器、主题,types.ts:137-288)留到 6.9 pi-tui 的语境里理解;自定义工具的ToolDefinition与斜杠命令的细节见 7.3 自定义工具与斜杠命令;registerProvider的完整链路见 7.4 自定义 Provider;turn_end/agent_before_settle边界能写入的上下文编辑与压缩条目,要结合 6.5 Session 存储格式与会话树 与 6.6 Context 构造与 Compaction 理解。
✅ 自测问题参考答案先自己回答,再点开对照
- 调用会失败。扩展的生命周期分两阶段:工厂函数里只能登记,动作方法此时还是桩——
setModel这类要动会话的 API 要等bindCore之后才被点亮。这批桩在loader.ts:153-221,其中大多数直接同步抛错;setModel的桩稍有不同,它返回一个以Extension runtime not initialized拒绝的 Promise(loader.ts:178)。所以写成await pi.setModel(...)时,错误从工厂里抛出,这个扩展整个被弃掉(错误记进LoadExtensionsResult.errors,启动期还会因此process.exit(1));不 await 的话,得到的是一个无人处理的 Promise 拒绝,模型也不会被切换。想在启动时改模型,正确做法是登记一个session_starthandler,在那里调用。(唯一的例外是registerProvider:它在工厂期不抛错,而是排队等bindCore时统一 flush。) - 不会被调用。
tool_call属于短路型事件:runner 按「扩展加载顺序 → 注册顺序」逐个 await,第一个给出决定的 handler 获胜。A 返回undefined表示不干预,继续;B 返回{ block: true },runner 立刻返回,C 及其后的 handler 根本不会执行(runner.ts:1134-1152,对应图 7.1-2)。顺带一提,emitToolCall是全部emit*里唯一没有 try/catch 的——守门扩展自己崩了也会阻断工具,这是刻意的失败安全。 - 毫无关系,只是重名。
pi.on是生命周期事件:宿主在特定时刻构造事件对象交给ExtensionRunner顺序 await 分发,事件名来自一个固定的可辨识联合,handler 的返回值会被采纳。pi.events是 33 行的EventEmitter薄包装,只服务扩展之间的自定义频道:频道名任意、由扩展自己 emit、返回值无人关心、异常只被console.error记录。在pi.events上监听"session_start"只会等到另一个扩展也往这个频道 emit 时才被触发。 - 因为
/reload会重新执行工厂函数——被缓存的只是模块(那个工厂),工厂本身每次都会重跑,于是闭包变量全部回到初值(回归测试extension-factory-cache.test.ts:69钉的就是这条语义)。而且旧的pi/ctx会被invalidate整体失效,之后再调用就抛错。要跨 reload 保留状态,得把它写到闭包之外——文件、设置,或别的进程外存储。 - 因为
continue只保证了加载器这一层不中断:它跑完剩下的扩展,把失败记进errors。但调用方不打算宽容——main.ts:786-789把这些 errors 升格为 error 级诊断,main.ts:897-906直接process.exit(1)。所以一个扩展写坏,整个 pi 起不来,无论它是-e显式传入还是自动发现的。interactive-mode.ts:1875那条「渲染成面板诊断」的路径只可能在启动之后走到,比如会话已经跑起来后执行/reload。