7.1 Extension 系统
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:第五、六部分把 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:1494-1495
/** 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:426-428,报错文案在loader.ts:472)。测试packages/coding-agent/test/extensions-discovery.test.ts:410专门覆盖了「只有具名导出、没有默认导出」这一情况。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:173-175)。正确做法是把动作放进事件 handler 或命令 handler 里——那时候运行时已经就位。唯一的例外是 registerTool:它在加载期就合法(loader.ts:193-194 把 refreshTools 预置成空函数)。 回到 Pi 源码:扩展从磁盘到内存
第一步:在哪里找
自动发现有两个固定位置,外加显式配置的第三类。顺序写死在 discoverAndLoadExtensions 里(源码事实,packages/coding-agent/src/core/extensions/loader.ts:678-723):项目本地在前、全局其次、显式路径最后,中间用一个 Set 去重(loader.ts:687-697)。
- 项目本地:
<cwd>/.pi/extensions/(loader.ts:699-701)。.pi这个目录名来自CONFIG_DIR_NAME(packages/coding-agent/src/config.ts:491,可被package.json的piConfig.configDir改写)。 - 全局:
~/.pi/agent/extensions/(loader.ts:703-705)。 - 显式路径:设置文件里配置的目录,或命令行
--extension/-e(packages/coding-agent/src/cli/args.ts:150-152;帮助文本见args.ts:266,也可在真实采集的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:2374-2382 的 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:153 导出,供 SDK(软件开发工具包)使用。真实启动路径是 ResourceLoader 委托 PackageManager 算出路径清单,再调 loadExtensionsCached(resource-loader.ts:548-558);扩展目录由 package-manager.ts:2342-2353 给出,目录内规则与上面等价。两条路径的发现规则一致,所以本章用更易读的 discoverAndLoadExtensions 讲解规则,用 ResourceLoader 讲解真实时序。 第二步:怎么加载一个 .ts 文件
Node.js 本身不能直接 import 一个 .ts 文件。Pi 用 jiti 解决这件事:
createJiti// packages/coding-agent/src/core/extensions/loader.ts:413-424
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// Bun uses modules embedded in the executable. Source TypeScript reuses the
// host-resolved modules and root tsconfig paths. Built Node uses dist aliases.
...(isBunBinary
? { virtualModules: VIRTUAL_MODULES, tryNative: false }
: isTypeScriptSourceRuntime
? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true }
: { alias: getAliases() }),
});
const module = await jiti.import(extensionPath, { default: true });这十二行里藏着一个真实的工程难题。扩展写的是 import ... from "@earendil-works/pi-coding-agent",但用户的 ~/.pi/agent/extensions/ 目录里根本没有 node_modules——这个包名要怎么解析?三种运行形态答案不同:
| 运行形态 | 策略 | 说明 |
|---|---|---|
| Bun 编译的单二进制 | virtualModules | 框架包已经被打进二进制,用一张虚拟模块表把包名映射到内存里的模块(表见 loader.ts:48-72) |
TypeScript 源码直跑(./pi-test.sh) | virtualModules + tsconfigPaths | 复用宿主已解析好的模块,并让 jiti 认识根 tsconfig 的 path 映射 |
| 构建后的 Node | alias | 把包名重定向到 dist/ 下的真实文件(getAliases(),loader.ts:84-140) |
官方文档说明「Extensions are loaded via jiti, so TypeScript works without compilation」(来源文件:packages/coding-agent/docs/extensions.md:179),与这段实现一致。
moduleCache: false 关掉了 jiti 自己的缓存,Pi 用自己的一层缓存代替(loader.ts:144-166),缓存键是解析后的绝对路径,缓存令牌绑定 cwd 与一个自增的 generation。/reload 时 clearExtensionCache() 被调用(resource-loader.ts:390-392),generation 自增让旧令牌全部失效。注意缓存的是模块(即工厂函数),工厂本身每次仍会重新执行——回归测试 packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts:69 就是钉这条语义的。
第三步:完整加载链路
loadExtension// packages/coding-agent/src/core/extensions/loader.ts:466-484(有省略)
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath, cacheToken);
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
const extension = createExtension(extensionPath, resolvedPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
await factory(api);
return { extension, error: null };
} catch (err) {
// …(省略:481-484 行,把异常包成 "Failed to load extension: <message>")
}createExtension(loader.ts:438-457)造出来的是一个全是空 Map 的容器:handlers、tools、commands、flags、shortcuts、messageRenderers、entryRenderers(类型定义见 types.ts:1670-1682)。工厂函数每调一次 pi.registerXxx,就往其中一个 Map 里塞一条。
图 7.1-1 扩展从磁盘到可用的完整链路
从上到下是一次启动。上半段(B→K)是「注册期」,下半段(L→N)是「运行期」的开端。请特别关注 G 到 J 这四步:每个扩展都是独立地走一遍,任何一个失败只产生一条 error 记录,不影响其它扩展。右侧分支 X 是失败出口。图中每个节点都标注了真实源码位置。
图里 L 之后的三步是本章最容易被忽略的部分:工厂执行完的时候,扩展还处于「半残」状态。下一节解释为什么。
ExtensionAPI:pi 上到底有什么
ExtensionAPI按用途分成六组(全部来自 types.ts:1185-1420,行号为该能力在文件中的位置):
| 组别 | 方法 | 位置 |
|---|---|---|
| 订阅生命周期事件 | on(event, handler),33 个重载 | types.ts:1190-1231 |
| 注册 | registerTool / registerCommand / registerShortcut / registerFlag / registerMessageRenderer / registerEntryRenderer | types.ts:1237-1279 |
| 注入消息 | sendMessage / sendUserMessage / appendEntry | types.ts:1285-1301 |
| 会话与执行 | setSessionName / getSessionName / setLabel / exec / getActiveTools / getAllTools / setActiveTools / getCommands | types.ts:1307-1329 |
| 模型 | setModel / getThinkingLevel / setThinkingLevel / registerProvider / unregisterProvider | types.ts:1335-1342、1400-1416 |
| 扩展间通信 | events: EventBus | types.ts:1418-1419 |
注意 pi 上没有 UI(用户界面)方法。界面能力挂在 ctx.ui 上(ExtensionUIContext,types.ts:131-282),只有事件 handler 和命令 handler 能拿到 ctx。这个划分不是随意的:界面在工厂执行时还不存在。
注册期与运行期
createExtensionAPI(loader.ts:232-395)把 pi 的方法分成泾渭分明的两类:
- 注册类(
on、registerTool、registerCommand…):直接往extension对象的 Map 里写,loader.ts:240-296。加载期立即可用。 - 动作类(
sendMessage、setModel、exec…):全部转发给一个共享的ExtensionRuntime对象,loader.ts:305-391。
而这个 runtime 在扩展加载时是空的:
createExtensionRuntime// packages/coding-agent/src/core/extensions/loader.ts:172-194(有省略)
export function createExtensionRuntime(): ExtensionRuntime {
const notInitialized = () => {
throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
};
// …(省略:176-181 行的 staleMessage / assertActive)
const runtime: ExtensionRuntime = {
sendMessage: notInitialized,
sendUserMessage: notInitialized,
// …(省略:186-192 行,其余动作方法同样指向 notInitialized)
// registerTool() is valid during extension load; refresh is only needed post-bind.
refreshTools: () => {},
// …(省略:195-221 行,其余字段与 provider 排队逻辑)
};
return runtime;
}真正的实现要等 ExtensionRunner.bindCore(packages/coding-agent/src/core/extensions/runner.ts:313-411)把 AgentSession._bindExtensionCore(packages/coding-agent/src/core/agent-session.ts:2331-2357)提供的函数逐个拷进 runtime。因为所有扩展共享同一个 runtime 对象,这一次拷贝就同时「点亮」了全部扩展。
registerProvider 是这套两阶段设计里最精巧的一处:工厂期调用它不会抛错,而是把注册请求排进 pendingProviderRegistrations 队列(loader.ts:210-212),bindCore 里再统一 flush(runner.ts:352-386)。官方文档说明「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 会被整体失效:runtime.invalidate 写入一条 staleMessage(loader.ts:203-207、runner.ts:542-549),此后每个 API 调用前的 runtime.assertActive()(如 loader.ts:241)都会抛错。一种看法是,这比让旧对象静默地操作已经关闭的会话要安全得多;代价是扩展作者必须理解「不要跨会话缓存 ctx」这条规矩,而这条规矩只能靠报错文案来传达。
两套事件系统,别搞混
Pi 里有两个都叫「事件」的东西,机制完全不同。
A. 生命周期事件:pi.on(...)
这套不是 EventEmitter。宿主在特定时刻构造一个事件对象,交给 ExtensionRunner 顺序分发;handler 的返回值往往会被采纳。事件全集是一个可辨识联合(Discriminated Union),共 25 个成员(types.ts:1034-1059,其中 SessionEvent 自己又是 9 个会话事件的子联合,types.ts:654-663),对应 pi.on 上的 33 个重载。
分发核心只有三十行:
RunnerEmitResult// packages/coding-agent/src/core/extensions/runner.ts:796-825(有省略)
async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> {
const ctx = this.createContext();
let result: SessionBeforeEventResult | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get(event.type);
if (!handlers || handlers.length === 0) continue;
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) {
// …(省略:815-822 行,包成 ExtensionError 交给 emitError 上报)
}
}
}
return result as RunnerEmitResult<TEvent>;
}按 handler 返回值的用法,可以把事件分成四类(分析解释,分类依据是 runner 里各个 emit* 方法的实现):
| 类别 | 代表事件 | 返回值语义 | 实现 |
|---|---|---|---|
| 观察型 | agent_start、turn_end、tool_execution_* | 忽略 | runner.ts:796 通用 emit |
| 链式改写型 | context、message_end、tool_result、before_provider_request | 后一个 handler 收到前一个的结果 | runner.ts:979 / 830 / 872 / 1011 |
| 短路型 | tool_call 的 block、input 的 handled、session_before_* 的 cancel | 第一个给出决定的 handler 获胜 | runner.ts:927 / 1191 / 796 |
| 聚合型 | resources_discover、before_agent_start | 各 handler 的产出被合并 | runner.ts:1142 / 1076 |
事件从哪儿发出来?主要有三条注入点(源码事实):
- Agent 事件桥接:
agent.subscribe(this._handleAgentEvent)(agent-session.ts:393)→_handleAgentEvent(agent-session.ts:595)→_emitExtensionEvent(定义在agent-session.ts:712)把 agent 层事件翻译成扩展事件。 - 工具钩子:
_installAgentToolHooks把 agent-core 的两个钩子接到 runner 上。 - Provider 链:
packages/coding-agent/src/core/sdk.ts:318-354把 header 变换、payload 变换、响应回调、上下文变换四件事接出来。
图 7.1-2 tool_call 事件的分发与短路
从上到下是一次工具调用被拦截的过程。请关注两点:其一,扩展 A 与扩展 B 是顺序执行的,顺序由加载顺序决定;其二,扩展 B 返回 block 之后 runner 立刻返回,扩展 C 及之后的 handler 根本不会被调用。参与者对应的源码分别是 packages/agent/src、agent-session.ts:469、runner.ts:927。
工具拦截的挂接点写得很直白:
_installAgentToolHooksemitToolCall 是全部 emit* 方法里唯一没有 try/catch 的(runner.ts:927-948)。官方文档说明「tool_call errors block the tool (fail-safe)」(来源文件:packages/coding-agent/docs/extensions.md:2865)。也就是说:守门扩展自己崩了,Pi 选择「宁可不执行」而不是「当作放行」。据此推断(尚未在源码中直接证实),这是刻意的失败安全设计——该方法上没有注释说明意图,判断依据是文档与行为的一致性。
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:257),一路传进 loadExtensionsCached 再传进 createExtensionAPI,最后挂到 pi.events 上(loader.ts:391)。同一次加载里的所有扩展拿到的是同一个实例——这就是它能当作扩展间通信管道的原因。官方文档把它描述为 "Shared event bus for communication between extensions"(来源文件:packages/coding-agent/docs/extensions.md:1674-1676)。
图 7.1-3 两套事件系统的形态对比
左右两块互不相通。左边:事件由宿主发起,事件名来自一个固定的联合类型,handler 的返回值会影响宿主行为。右边:事件由扩展发起,频道名任意,返回值无人关心,异常只被 console.error 记录。读者要关注的关键差别是「谁发起」和「返回值有没有人看」。左边对应 runner.ts,右边对应 event-bus.ts 全文。
关于 pi.events 有一个尚未确认的点:/reload 时并未看到 EventBusController.clear() 被调用,而 invalidate 只保护 pi/ctx 上的 API、不会撤销已注册的 bus 监听。旧扩展闭包留下的 bus handler 在 reload 后是否会累积,需要实验验证,本书不给结论。
错误隔离:扩展抛错会怎样
把前面的碎片拼起来,一共四种情况(四条都是源码事实):
- 工厂函数抛错(或默认导出根本不是函数):
loadExtension的 try/catch 把它变成LoadExtensionsResult.errors里的一条(loader.ts:481-484),加载循环继续跑完剩下的扩展(loader.ts:531-534:errors.push(...)之后continue)。 - 普通事件 handler 抛错:被
emit的 try/catch 捕获,包成ExtensionError交给emitError上报,循环继续执行后面的 handler(runner.ts:814-823)。测试packages/coding-agent/test/extensions-runner.test.ts:569(calls error listeners when handler throws)覆盖了这条路径。 tool_callhandler 抛错:不被捕获,一路传到_installAgentToolHooks被重新抛出,阻断本次工具执行(agent-session.ts:482-487)。- 命令 handler 抛错:被
_tryExecuteExtensionCommand捕获上报,并且仍然返回true(agent-session.ts:1285-1293)——也就是说这条输入被认为「已处理」,不会退回去当成给模型的提示词。
官方文档把第 2 条概括为 "Extension errors are logged, agent continues"(来源文件:packages/coding-agent/docs/extensions.md:2864)。
「加载循环继续」是 loader 这一层的事实,不代表进程能活下来。main() 把 resourceLoader.getExtensions().errors 里的每一条都翻译成 error 级启动诊断(源码事实,packages/coding-agent/src/main.ts:735-738),随后只要诊断里出现任何一条 error,就打印一行提示并 process.exit(1)(main.ts:844-849)。
换句话说:一个扩展写坏,整个 pi 起不来——无论它是 -e 显式传入的还是从目录里自动发现的,也无论同批次里其它扩展是否完好。本章实践任务第 5 步会亲手复现这一点(作者已实测,见其中的真实输出与退出码)。
那 interactive-mode.ts:1627 读取同一份 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:1300-1301),把一条不进入 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:1122-1129)→ _tryExecuteExtensionCommand(agent-session.ts:1270)→ runner.getCommand(name)(runner.ts:647)→ 用 createCommandContext() 造出命令专用的 ctx 再执行。命中之后直接 return:input 事件、skill 展开、模板展开、发给模型,一律不发生。内置命令则完全是另一套——BUILTIN_SLASH_COMMANDS(packages/coding-agent/src/core/slash-commands.ts:19-42)只是一份 22 条的数据清单,实现在交互模式里。多个扩展注册同名命令时,Pi 按注册顺序生成 name:1、name:2 消歧(runner.ts:598-632,测试见 extensions-runner.test.ts:477)。
③ before_agent_start 是聚合型事件。返回 { systemPrompt } 会替换本轮的系统提示词;多个扩展都返回时,emitBeforeAgentStart(runner.ts:1076-1140)把它们链起来,后一个扩展的 event.systemPrompt 是前一个改过的值。测试 extensions-runner.test.ts:703 断言了 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.83.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.83.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:1300-1314 定义、:1331 排序)。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:528;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:1580 的 addLoadedSection("Extensions", …) 渲染的;③ 说出步骤 5 里 Extension does not export a valid factory function 这段文案来自 loader.ts:472,而判定发生在 loader.ts:426-428;④ 说出外层的 Failed to load extension "…" 与那句 Hint: 分别来自 main.ts:737 和 main.ts:846,退出发生在 main.ts:848。
常见错误:
· 忘了 -e,只把文件放在 /tmp 里——/tmp 不是发现目录,什么都不会发生。想走自动发现,要放进 ~/.pi/agent/extensions/ 或项目的 .pi/extensions/。
· 在有 .pi 目录的仓库里启动会先弹「Trust project folder?」,需要先做出选择;这就是 package-manager.ts:2374 那道信任闸门。真实采集的这个界面见 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:354);网络不通时改打印 Failed to download fd: … 并继续启动(tools-manager.ts:365),不影响本任务。作者的机器上已缓存该二进制,所以上面的采集里没有这两行。
对应源码位置:packages/coding-agent/src/cli/args.ts:150(-e 解析)、src/core/extensions/loader.ts:405-433(jiti 加载与合法性判定)、loader.ts:459-485(单个扩展加载)、src/core/extensions/runner.ts:598-632(命令解析)、src/modes/interactive/interactive-mode.ts:1572-1581(清单渲染)、src/main.ts:735-738 与 :844-849(把加载错误升格为启动失败)。
本章小结
- 扩展的全部形态是
ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>(types.ts:1495)。默认导出不是函数就会被判为无效。 - 发现有三个来源:项目
.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:735-738把它升格为 error 级诊断,main.ts:844-849直接process.exit(1)——一个坏扩展会让整个 pi 起不来。 - 关键术语:扩展(Extension)、工厂函数(Factory Function)、jiti、ExtensionAPI、ExtensionRuntime、ExtensionRunner、事件总线(Event Bus)、失败安全(fail-safe)。
- 关键源码索引:
packages/coding-agent/src/core/extensions/types.ts:1185-1420(ExtensionAPI)、:1034-1059(事件联合)、:1670-1682(Extension 容器)packages/coding-agent/src/core/extensions/loader.ts:172-225(runtime 桩)、:232-395(造 pi)、:405-433(jiti)、:459-485(加载)、:631-726(发现)packages/coding-agent/src/core/extensions/runner.ts:313-411(bindCore)、:796-828(通用分发)、:927-948(tool_call)packages/coding-agent/src/core/event-bus.ts:1-33(全文)packages/coding-agent/src/core/agent-session.ts:468-518(工具钩子)、:712-793(事件桥接)、:2331-2357(bindCore 注入)packages/coding-agent/src/main.ts:735-738、: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:131-282)留到 6.9 pi-tui 的语境里理解;自定义工具的ToolDefinition与斜杠命令的细节见 7.3 自定义工具与斜杠命令;registerProvider的完整链路见 7.4 自定义 Provider;pi.events在/reload后是否泄漏监听,本书标记为尚未确认。