Skip to content

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 的大对象作为唯一参数传进去,调用它一次,然后就再也不调用了。

这个函数体里发生的一切都是「登记」:登记我要监听哪些事件、我提供哪个工具、我占用哪个斜杠命令。函数返回之后,扩展这个「文件」的使命就结束了——真正在运行的,是它留下的那些闭包(回调函数)。

📘 概念工厂函数(Factory Function)
一个「被调用一次、用于产出配置或注册副作用」的函数。Pi 把它叫做 ExtensionFactory。它跟 class 的构造函数扮演同样的角色:给你一次机会把自己接进宿主,之后宿主只跟你登记过的回调打交道。
earendil-works/pi@c13ffe1第 1494–1495 行在 GitHub 查看 ↗
扩展的全部形态就这一行类型:接收一个 ExtensionAPI,返回 void 或 Promise。返回 Promise 时 Pi 会 await 它。
ts
// 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:

ts
// /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");
		},
	});
}

三个要点:

  1. 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 专门覆盖了「只有具名导出、没有默认导出」这一情况。
  2. import type 只是为了类型提示。@earendil-works/pi-coding-agent 这个包名在扩展被加载时由 Pi 自己解析,不需要你在扩展旁边跑 npm install(下一节讲 jiti 时会解释)。
  3. handler 的第二个参数 ctx 与工厂参数 pi两个不同的对象pi 用来注册,ctx 用来在运行时访问会话与界面。混淆这两个是最常见的入门错误。
⚠️ 常见误解在工厂函数里直接调用 pi.sendMessage()
工厂函数执行的时候,会话还没绑定完成。此时 pi 上所有「动作类」方法都是一个只会抛错的桩函数,错误文案是 Extension runtime not initialized. Action methods cannot be called during extension loading.(源码事实,loader.ts:173-175)。正确做法是把动作放进事件 handler 或命令 handler 里——那时候运行时已经就位。唯一的例外是 registerTool:它在加载期就合法(loader.ts:193-194refreshTools 预置成空函数)。

回到 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_NAMEpackages/coding-agent/src/config.ts:491,可被 package.jsonpiConfig.configDir 改写)。
  • 全局:~/.pi/agent/extensions/loader.ts:703-705)。
  • 显式路径:设置文件里配置的目录,或命令行 --extension / -epackages/coding-agent/src/cli/args.ts:150-152;帮助文本见 args.ts:266,也可在真实采集的 research/cli-captures/pi-help.txt:41 里看到同一行)。

单个目录里的发现规则只有三条,而且只下探一层

earendil-works/pi@c13ffe1第 631–641 行在 GitHub 查看 ↗
目录内的三条发现规则写在函数注释里:直接的 .ts/.js 文件、子目录的 index.ts/index.js、子目录 package.json 的 "pi" 字段。最后一句明确「不递归超过一层」。
1. 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-2382if (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
discoverAndLoadExtensions 这个函数从 packages/coding-agent/src/index.ts:153 导出,供 SDK(软件开发工具包)使用。真实启动路径是 ResourceLoader 委托 PackageManager 算出路径清单,再调 loadExtensionsCachedresource-loader.ts:548-558);扩展目录由 package-manager.ts:2342-2353 给出,目录内规则与上面等价。两条路径的发现规则一致,所以本章用更易读的 discoverAndLoadExtensions 讲解规则,用 ResourceLoader 讲解真实时序。

第二步:怎么加载一个 .ts 文件

Node.js 本身不能直接 import 一个 .ts 文件。Pi 用 jiti 解决这件事:

earendil-works/pi@c13ffe1第 413–424 行在 GitHub 查看 ↗
按运行时形态选择模块解析策略,然后 jiti.import 取默认导出。三个分支分别对应 Bun 单二进制、TypeScript 源码直跑、构建后的 Node。
ts
// 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.shvirtualModules + tsconfigPaths复用宿主已解析好的模块,并让 jiti 认识根 tsconfig 的 path 映射
构建后的 Nodealias把包名重定向到 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。/reloadclearExtensionCache() 被调用(resource-loader.ts:390-392),generation 自增让旧令牌全部失效。注意缓存的是模块(即工厂函数),工厂本身每次仍会重新执行——回归测试 packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts:69 就是钉这条语义的。

第三步:完整加载链路

earendil-works/pi@c13ffe1第 459–485 行在 GitHub 查看 ↗
单个扩展的加载全过程:解析路径 → 取模块 → 建空 Extension 对象 → 造 pi 对象 → await 工厂。整个过程包在 try/catch 里,任何异常都变成一条错误记录而不是崩溃。
ts
// 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>")
}

createExtensionloader.ts:438-457)造出来的是一个全是空 Map 的容器handlerstoolscommandsflagsshortcutsmessageRenderersentryRenderers(类型定义见 types.ts:1670-1682)。工厂函数每调一次 pi.registerXxx,就往其中一个 Map 里塞一条。

图加载中…

图 7.1-1 扩展从磁盘到可用的完整链路
从上到下是一次启动。上半段(B→K)是「注册期」,下半段(L→N)是「运行期」的开端。请特别关注 G 到 J 这四步:每个扩展都是独立地走一遍,任何一个失败只产生一条 error 记录,不影响其它扩展。右侧分支 X 是失败出口。图中每个节点都标注了真实源码位置。

图里 L 之后的三步是本章最容易被忽略的部分:工厂执行完的时候,扩展还处于「半残」状态。下一节解释为什么。

ExtensionAPI:pi 上到底有什么

earendil-works/pi@c13ffe1第 1185–1231 行在 GitHub 查看 ↗
ExtensionAPI 的开头是 33 个 on() 重载,每个事件名对应一个精确的 handler 类型。这是整个扩展系统能力面的第一块。

按用途分成六组(全部来自 types.ts:1185-1420,行号为该能力在文件中的位置):

组别方法位置
订阅生命周期事件on(event, handler),33 个重载types.ts:1190-1231
注册registerTool / registerCommand / registerShortcut / registerFlag / registerMessageRenderer / registerEntryRenderertypes.ts:1237-1279
注入消息sendMessage / sendUserMessage / appendEntrytypes.ts:1285-1301
会话与执行setSessionName / getSessionName / setLabel / exec / getActiveTools / getAllTools / setActiveTools / getCommandstypes.ts:1307-1329
模型setModel / getThinkingLevel / setThinkingLevel / registerProvider / unregisterProvidertypes.ts:1335-13421400-1416
扩展间通信events: EventBustypes.ts:1418-1419

注意 pi没有 UI(用户界面)方法。界面能力挂在 ctx.ui 上(ExtensionUIContexttypes.ts:131-282),只有事件 handler 和命令 handler 能拿到 ctx。这个划分不是随意的:界面在工厂执行时还不存在。

注册期与运行期

createExtensionAPIloader.ts:232-395)把 pi 的方法分成泾渭分明的两类:

  • 注册类onregisterToolregisterCommand…):直接往 extension 对象的 Map 里写,loader.ts:240-296。加载期立即可用。
  • 动作类sendMessagesetModelexec…):全部转发给一个共享的 ExtensionRuntime 对象,loader.ts:305-391

而这个 runtime 在扩展加载时是空的:

earendil-works/pi@c13ffe1第 172–194 行在 GitHub 查看 ↗
初始 runtime 把所有动作方法指向同一个只会抛错的 notInitialized 桩;registerTool 依赖的 refreshTools 是唯一被预置成空函数的例外。
ts
// 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.bindCorepackages/coding-agent/src/core/extensions/runner.ts:313-411)把 AgentSession._bindExtensionCorepackages/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 之后,旧的 pictx 会被整体失效:runtime.invalidate 写入一条 staleMessage(loader.ts:203-207runner.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 个重载。

分发核心只有三十行:

earendil-works/pi@c13ffe1第 796–828 行在 GitHub 查看 ↗
通用 emit:外层按扩展加载顺序,内层按注册顺序,逐个 await。handler 抛错走 emitError 上报后继续;session_before_* 类事件返回 cancel 时立即短路。
ts
// 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_startturn_endtool_execution_*忽略runner.ts:796 通用 emit
链式改写型contextmessage_endtool_resultbefore_provider_request后一个 handler 收到前一个的结果runner.ts:979 / 830 / 872 / 1011
短路型tool_callblockinputhandledsession_before_*cancel第一个给出决定的 handler 获胜runner.ts:927 / 1191 / 796
聚合型resources_discoverbefore_agent_start各 handler 的产出被合并runner.ts:1142 / 1076

事件从哪儿发出来?主要有三条注入点(源码事实):

  • Agent 事件桥接:agent.subscribe(this._handleAgentEvent)agent-session.ts:393)→ _handleAgentEventagent-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。

工具拦截的挂接点写得很直白:

earendil-works/pi@c13ffe1第 468–488 行在 GitHub 查看 ↗
beforeToolCall 钩子先用 hasHandlers 做零成本门控,没有扩展监听就直接返回;有监听则调 emitToolCall,并把任何异常重新抛出——异常向上传播就等于阻断这次工具执行。

emitToolCall 是全部 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 行:

earendil-works/pi@c13ffe1第 12–33 行在 GitHub 查看 ↗
EventBus 的全部实现:emit 直接转发,on 把 handler 包一层 async try/catch 并返回退订函数,clear 清空所有监听。频道名是任意字符串,Pi 自己不使用任何频道。
ts
// 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 后是否会累积,需要实验验证,本书不给结论。

错误隔离:扩展抛错会怎样

把前面的碎片拼起来,一共四种情况(四条都是源码事实):

  1. 工厂函数抛错(或默认导出根本不是函数)loadExtension 的 try/catch 把它变成 LoadExtensionsResult.errors 里的一条(loader.ts:481-484),加载循环继续跑完剩下的扩展(loader.ts:531-534errors.push(...) 之后 continue)。
  2. 普通事件 handler 抛错:被 emit 的 try/catch 捕获,包成 ExtensionError 交给 emitError 上报,循环继续执行后面的 handler(runner.ts:814-823)。测试 packages/coding-agent/test/extensions-runner.test.ts:569calls error listeners when handler throws)覆盖了这条路径。
  3. tool_call handler 抛错:不被捕获,一路传到 _installAgentToolHooks 被重新抛出,阻断本次工具执行agent-session.ts:482-487)。
  4. 命令 handler 抛错:被 _tryExecuteExtensionCommand 捕获上报,并且仍然返回 trueagent-session.ts:1285-1293)——也就是说这条输入被认为「已处理」,不会退回去当成给模型的提示词。

官方文档把第 2 条概括为 "Extension errors are logged, agent continues"(来源文件:packages/coding-agent/docs/extensions.md:2864)。

⚠️ 常见误解把第 1 条读成「坏扩展只会被跳过,Pi 照常启动」

「加载循环继续」是 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)改写。

earendil-works/pi@c13ffe1第 15–46 行在 GitHub 查看 ↗
官方示例 pirate.ts:一个布尔量存在工厂闭包里,/pirate 命令翻转它,before_agent_start 在开启时给本轮系统提示词追加一段指令。
ts
// 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.appendEntrytypes.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)→ _tryExecuteExtensionCommandagent-session.ts:1270)→ runner.getCommand(name)runner.ts:647)→ 用 createCommandContext() 造出命令专用的 ctx 再执行。命中之后直接 returninput 事件、skill 展开、模板展开、发给模型,一律不发生。内置命令则完全是另一套——BUILTIN_SLASH_COMMANDSpackages/coding-agent/src/core/slash-commands.ts:19-42)只是一份 22 条的数据清单,实现在交互模式里。多个扩展注册同名命令时,Pi 按注册顺序生成 name:1name:2 消歧(runner.ts:598-632,测试见 extensions-runner.test.ts:477)。

before_agent_start 是聚合型事件。返回 { systemPrompt } 会替换本轮的系统提示词;多个扩展都返回时,emitBeforeAgentStartrunner.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 的角色。

实践任务

🛠 实践任务写一个 hello 扩展,并在启动清单里看到它

目标:不需要任何 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 取回纯文本,未作改写):

text
 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.ts

path 是该扩展的来源分组。分组一共只有三种——projectuserpath,按这个顺序排列(源码事实,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] 是来源作用域标记:useruprojectp、其余(也就是 temporary)→ t(源码事实,src/modes/interactive/interactive-mode.ts:528SourceScope 的三个取值见 src/core/source-info.ts:3,合成来源默认就是 temporarysource-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:1580addLoadedSection("Extensions", …) 渲染的;③ 说出步骤 5 里 Extension does not export a valid factory function 这段文案来自 loader.ts:472,而判定发生在 loader.ts:426-428;④ 说出外层的 Failed to load extension "…" 与那句 Hint: 分别来自 main.ts:737main.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.reloadloadExtensionsCachedloadExtensionjiti.importcreateExtensionAPIawait factory(pi)new ExtensionRunnerbindCoresession_start
  • 注册期只能注册,动作方法是抛错的桩,bindCore 之后才点亮;registerProvider 用排队的方式绕开了这条限制。
  • 两套事件系统:pi.on 是宿主发起、顺序 await、返回值被采纳的生命周期事件;pi.events 是 33 行的 EventEmitter 包装,只服务扩展之间的自定义频道。
  • 错误隔离分四档:工厂抛错弃掉该扩展、普通 handler 抛错记录后继续、tool_call handler 抛错阻断工具、命令 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.tstest/extensions-runner.test.tstest/extensions-input-event.test.tstest/suite/regressions/extension-factory-cache.test.ts
  • 自测问题
    1. 一个扩展的工厂函数里写了 pi.setModel(...),启动时会发生什么?错误信息由哪个函数产生?
    2. 扩展 A 和扩展 B 都监听了 tool_call,A 返回 undefined、B 返回 { block: true },C 也监听了同一事件。C 的 handler 会被调用吗?为什么?
    3. pi.on("session_start", …)pi.events.on("session_start", …) 有什么关系?
    4. 想让扩展的状态在 /reload 后仍然存在,闭包变量为什么不够用?
    5. loadExtensionsInternal 在某个扩展加载失败时会 continue 跑完剩下的——为什么这不意味着「坏扩展只是被跳过、Pi 照常启动」?请指出把它变成启动失败的那两处代码。
  • 下一章7.2 Skill 系统——技能(Skill)是另一条定制路径,它不写代码、只写 Markdown,与扩展在斜杠命令空间里共存。
  • 本章尚未展开的内容ExtensionUIContext 的完整能力面(对话框、widget、自定义编辑器、主题,types.ts:131-282)留到 6.9 pi-tui 的语境里理解;自定义工具的 ToolDefinition 与斜杠命令的细节见 7.3 自定义工具与斜杠命令registerProvider 的完整链路见 7.4 自定义 Providerpi.events/reload 后是否泄漏监听,本书标记为尚未确认。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)