Skip to content

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

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

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

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:503-505,报错文案在 loader.ts:570)。测试 packages/coding-agent/test/extensions-discovery.test.ts:414 专门覆盖了「只有具名导出、没有默认导出」这一情况。
  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: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 里看到同一行)。

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

earendil-works/pi@16787ad第 702–712 行在 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: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
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 解决这件事:

earendil-works/pi@16787ad第 487–501 行在 GitHub 查看 ↗
先按需拿到 jiti 的 createJiti,再按运行时形态选择模块解析策略,最后 jiti.import 取默认导出。三个分支分别对应「内嵌模块」的发行形态、TypeScript 源码直跑、未打包的 Node 构建。
ts
// 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 就是钉这条语义的。

第三步:完整加载链路 ​

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

earendil-works/pi@16787ad第 536–555 行在 GitHub 查看 ↗
建空容器、造 pi,然后 await 工厂。工厂成功返回才 commit;工厂抛错则 discard 并把异常继续抛给 loadExtension。
ts
// 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 只向前推进一个阶段。

从扩展文件到资源发现,严格走一遍现役初始化顺序。

加载阶段1 / 8

module import

await loadExtensionModule(resolvedPath)

loader 导入模块并取得默认导出的 factory;此时还没有 ExtensionRunner。

此刻状态:模块已求值,factory 已取得。

启动预设已就绪。使用“下一步”逐步查看。

ExtensionAPI:pi 上到底有什么 ​

earendil-works/pi@16787ad第 1349–1419 行在 GitHub 查看 ↗
ExtensionAPI 的开头是 39 个 on() 重载,每个事件名对应一个精确的 handler 类型,每个重载都返回一个取消订阅函数。这是整个扩展系统能力面的第一块。

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

组别方法位置
订阅生命周期事件on(event, handler),39 个重载,返回取消订阅函数types.ts:1354-1419
注册registerTool / registerCommand / registerShortcut / registerFlag / registerMessageRenderer / registerMarkdownTransformer / registerEntryRenderertypes.ts:1425-1476
注入消息sendMessage / sendUserMessage / appendEntrytypes.ts:1482-1499
会话与执行setSessionName / getSessionName / setLabel / exec / getActiveTools / getAllTools / setActiveTools / getCommandstypes.ts:1505-1527
模型setModel / getThinkingLevel / setThinkingLevel / registerProvider / unregisterProvidertypes.ts:1537-1546、1604-1620
扩展间通信events: EventBustypes.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 在扩展加载时是空的:

earendil-works/pi@16787ad第 153–221 行在 GitHub 查看 ↗
初始 runtime 把所有动作方法指向同一个只会抛错的 notInitialized 桩;registerTool 依赖的 refreshTools 是唯一被预置成空函数的例外。
ts
// 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 个重载。

分发核心只有三十行:

earendil-works/pi@16787ad第 988–1017 行在 GitHub 查看 ↗
通用 emit:外层按扩展加载顺序,内层按注册顺序,逐个 await。handler 抛错走 emitError 上报后继续;session_before_* 类事件返回 cancel 时立即短路。
ts
// 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。

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

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

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

earendil-works/pi@16787ad第 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: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() 之后归零,而宿主自己挂在总线上的监听不受影响。

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

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

  1. 工厂函数抛错(或默认导出根本不是函数):loadExtension 的 try/catch 把它变成 LoadExtensionsResult.errors 里的一条(loader.ts:576-579),加载循环继续跑完剩下的扩展(loader.ts:622-625:errors.push(...) 之后 continue)。
  2. 普通事件 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)覆盖了这条路径。
  3. tool_call handler 抛错:不被捕获,一路传到 _installAgentToolHooks 被重新抛出,阻断本次工具执行(agent-session.ts:543-548)。
  4. 命令 handler 抛错:被 _tryExecuteExtensionCommand 捕获上报,并且仍然返回 true(agent-session.ts:1780-1788)——也就是说这条输入被认为「已处理」,不会退回去当成给模型的提示词。

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

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

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

earendil-works/pi@16787ad第 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.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 的角色。

实践任务 ​

🛠 实践任务写一个 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.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.ts

path 是该扩展的来源分组。分组一共只有三种——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_call handler 抛错阻断工具、命令 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
  • 自测问题:
    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: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 理解。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 调用会失败。扩展的生命周期分两阶段:工厂函数里只能登记,动作方法此时还是桩——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_start handler,在那里调用。(唯一的例外是 registerProvider:它在工厂期不抛错,而是排队等 bindCore 时统一 flush。)
  2. 不会被调用。tool_call 属于短路型事件:runner 按「扩展加载顺序 → 注册顺序」逐个 await,第一个给出决定的 handler 获胜。A 返回 undefined 表示不干预,继续;B 返回 { block: true },runner 立刻返回,C 及其后的 handler 根本不会执行(runner.ts:1134-1152,对应图 7.1-2)。顺带一提,emitToolCall 是全部 emit* 里唯一没有 try/catch 的——守门扩展自己崩了也会阻断工具,这是刻意的失败安全。
  3. 毫无关系,只是重名。pi.on 是生命周期事件:宿主在特定时刻构造事件对象交给 ExtensionRunner 顺序 await 分发,事件名来自一个固定的可辨识联合,handler 的返回值会被采纳。pi.events 是 33 行的 EventEmitter 薄包装,只服务扩展之间的自定义频道:频道名任意、由扩展自己 emit、返回值无人关心、异常只被 console.error 记录。在 pi.events 上监听 "session_start" 只会等到另一个扩展也往这个频道 emit 时才被触发。
  4. 因为 /reload 会重新执行工厂函数——被缓存的只是模块(那个工厂),工厂本身每次都会重跑,于是闭包变量全部回到初值(回归测试 extension-factory-cache.test.ts:69 钉的就是这条语义)。而且旧的 pi / ctx 会被 invalidate 整体失效,之后再调用就抛错。要跨 reload 保留状态,得把它写到闭包之外——文件、设置,或别的进程外存储。
  5. 因为 continue 只保证了加载器这一层不中断:它跑完剩下的扩展,把失败记进 errors。但调用方不打算宽容——main.ts:786-789 把这些 errors 升格为 error 级诊断,main.ts:897-906 直接 process.exit(1)。所以一个扩展写坏,整个 pi 起不来,无论它是 -e 显式传入还是自动发现的。interactive-mode.ts:1875 那条「渲染成面板诊断」的路径只可能在启动之后走到,比如会话已经跑起来后执行 /reload。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)