开源编程 Agent pi 的扩展加载器:从被发现到被执行要过几层
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 的扩展加载器里最值得抄的一处判断,不是它怎么扫目录找文件,而是它把「注册」和「执行」拆成了两个不同的对象:扩展在加载期拿到的 API,动作方法全是会抛异常的桩函数;等宿主准备好了,才由运行期把真实实现填进去。 这一刀切下去,扩展作者就不可能在还没有会话、还没有模型注册表的时候去发消息、切模型,也就不会出现那种「加载顺序稍微一变就随机崩」的老毛病。
pi 是一个 MIT 许可证的开源编程 Agent,仓库在 https://github.com/earendil-works/pi ,截至 2026-07 在 GitHub 上约有 8 万 star。它的扩展体系不走独立进程加协议那一套,扩展就是一个 TypeScript 文件,默认导出一个函数,在同一个进程里被调用。这个选择带来的所有好处和所有代价,都写在下面三个文件里:packages/coding-agent/src/core/extensions/loader.ts、runner.ts、wrapper.ts。
站内已经有两篇讲通用方法论的文章:MCP 与扩展机制的框架分层讲的是「一个 Agent 的扩展面该怎么切」,MCP Server 启动失败排查讲的是「进程起不来怎么定位」。本篇不重复那些结论,只做一件事:把 pi 这一个具体项目的扩展链路从头到尾走一遍,看它把方法论落到了哪些具体代码上,以及在哪些地方做了和通用做法不一样的取舍。
一、扩展是怎么被找到的:三个来源,一次去重
入口是 loader.ts 里的 discoverAndLoadExtensions。它按固定顺序收集路径:
第一处是项目本地目录,cwd 下的 .pi/extensions/。这里的 .pi 来自 config.ts 导出的 CONFIG_DIR_NAME,默认值是 .pi,但它是从包自身的 piConfig.configDir 读的,也就是说这个目录名可被打包方改掉,不要硬编码在你的脚本里。
第二处是全局目录,getAgentDir() 下的 extensions/。getAgentDir() 先看环境变量,没有才回落到 home 目录下的 .pi/agent。
第三处是显式配置进来的路径:如果配置的是目录,先尝试 resolveExtensionEntries 解析出正式入口,解析不出来才退化成「扫这个目录里的散文件」;不是目录就直接当成一个扩展文件路径。
三处收完之后统一去重,去重的键是 path.resolve(p) 后的绝对路径,但真正留在列表里的是原始路径字符串——这就是为什么报错时打印的是你写进配置的那个样子,不是展开后的样子。
目录内部的发现规则写在 discoverExtensionsInDir 里,只有三条:
- 目录下的
.ts或.js文件,直接算一个扩展; - 子目录里有
index.ts或index.js,算一个扩展; - 子目录里有
package.json且带pi字段,按字段里extensions数组声明的路径来。
规则明确写了不再往下递归。也就是说你把扩展埋在两层子目录里,除非用 package.json 的清单声明出来,否则它永远不会被发现。清单里声明的每个路径都会先做一次 existsSync 检查,不存在的会被静默丢掉;如果一个都不存在,就当作没有清单,继续去找 index.ts。这里的静默是刻意的,但对你来说意味着写错一个相对路径不会报错,只会「那个扩展不见了」。
还有一点容易被忽略:符号链接在这两条分支里都被当作有效条目处理,文件分支判断的是 entry.isFile() || entry.isSymbolicLink(),目录分支也带上了链接。所以把开发中的扩展仓库软链进 .pi/extensions/ 是被支持的用法。
二、模块是怎么被载进来的:两套解析策略
发现只是拿到了一串路径,真正把 TypeScript 变成可调用的函数,靠的是 loadExtensionModule 里的 jiti。它用 createJiti(import.meta.url, ...) 建实例,并且显式设了 moduleCache: false。
关键在于紧跟着的三元分支——pi 在这里维护了两套完全不同的依赖解析方式:
跑在 Node 或开发环境下,走 alias。getAliases() 会把 @earendil-works/pi-coding-agent、pi-agent-core、pi-tui、pi-ai 及其 compat、oauth、providers/all 子路径,还有 typebox 三个入口,全部指到具体的文件路径上。解析时先看 monorepo 里的工作区目录存不存在,存在就用工作区的 dist,不存在才回落到 import.meta.resolve。
跑在 Bun 编译出的单文件二进制里,走 virtualModules,并且额外设了 tryNative: false。文件顶部那一排看似多余的 import * as _bundledXxx 就是为这个服务的——注释写得很直白:这些导入必须是静态的,Bun 才会把它们打进二进制;然后再通过 virtualModules 把这些已经在内存里的模块对象喂给扩展。tryNative: false 是为了让 jiti 接管所有 import 而不只是入口那一个,否则扩展内部的二级依赖会绕过虚拟模块表去碰文件系统,而二进制里根本没有文件系统。
虚拟模块表里还藏着两条兼容信息,都是能直接影响你写扩展的:
一是 @earendil-works/pi-ai 这个根路径被解析到了 compat 入口,而不是核心入口。注释说明 compat 是核心的严格超集,目的是让还在用旧全局 API 的扩展继续跑得起来,并且明确写了「until compat is removed」。你依赖 compat 才有的东西,就是在依赖一个已被标注为将来会移除的东西。
二是表里同时列了 @mariozechner/ 前缀的一整套同名映射,指向和 @earendil-works/ 完全相同的模块对象。这是历史包名的兼容层,新写的扩展没有理由再用旧前缀。
模块导入完成后还有一道检查:jiti.import(extensionPath, { default: true }) 拿到的东西必须是 function,否则直接返回 undefined,上层报 Extension does not export a valid factory function。类型上这个工厂是 (pi: ExtensionAPI) => void | Promise<void>。
加载还有一层缓存。extensionCache 是路径到工厂的 Map,但它带了一个由 cwd 和 generation 组成的令牌:cwd 一变就整体清空,clearExtensionCache() 会让 generation 自增从而作废所有旧令牌。只有 loadExtensionsCached 会用缓存,loadExtensions 不用。资源加载器 core/resource-loader.ts 里走的是带缓存的那条路径。
最后是失败隔离:单个扩展的导入与工厂调用被 loadExtension 包在 try/catch 里,把异常转成一条错误字符串返回;外层 loadExtensionsInternal 逐个路径调用它,拿到错误就往 errors 数组里追加一条 { path, error } 然后 continue,其余扩展照常加载。这是「一个坏扩展不至于让整个 Agent 起不来」的实现位置。
三、注册期和执行期为什么必须分家
工厂函数被调用时,拿到的是 createExtensionAPI 造出来的对象。这个对象里的方法分成泾渭分明的两类。
一类是注册方法:on、registerTool、registerCommand、registerShortcut、registerFlag、registerMessageRenderer、registerEntryRenderer。它们全都只做一件事——往 Extension 这个纯数据对象的几个 Map 里写东西。Extension 本身在 createExtension 里被造出来,字段就是 handlers、tools、commands、flags、shortcuts、messageRenderers、entryRenderers 这几个空 Map 加上路径与来源信息。
另一类是动作方法:sendMessage、sendUserMessage、appendEntry、setSessionName、setActiveTools、setModel、setThinkingLevel 等。它们不自己干活,一律转发给共享的 ExtensionRuntime。
而 createExtensionRuntime() 造出来的初始运行期,这些动作方法全是同一个抛异常的桩:Extension runtime not initialized. Action methods cannot be called during extension loading. 这就是开头说的那一刀。你在工厂函数体里直接调 pi.sendMessage(...),收到的就是这句话。正确写法是把它放进某个事件处理器或命令处理器里,等宿主 bindCore() 之后再跑。
这里有两个刻意开的口子,值得单独记住:
refreshTools 的初始值是空函数而不是抛异常的桩,代码注释解释了原因——registerTool() 在加载期是合法的,只有绑定之后才需要真正刷新。
provider 注册走的是排队。加载期调用 registerProvider 只是把参数推进 pendingProviderRegistrations 或 pendingNativeProviderRegistrations;unregisterProvider 在这个阶段则是从队列里过滤掉对应项。等到 runner.ts 的 bindCore() 执行,才把队列冲刷进 ModelRegistry,冲刷完清空队列,并且当场把 runtime.registerProvider 这几个方法整个替换成直连注册表的版本。代码注释明确写了:从这一刻起,注册和反注册立即生效,不需要再 /reload。冲刷过程中单个 provider 抛错不会中断,而是走 emitError,事件名记为 register_provider。
还有一个和「过期上下文」有关的机制。运行期有 assertActive 和 invalidate:几乎每个 API 方法进来第一句都是 runtime.assertActive()。一旦会话被替换或重载,invalidate 会写下一段很长的提示,告诉你不要在 ctx.newSession()、ctx.fork()、ctx.switchSession()、ctx.reload() 之后继续用捕获住的旧 ctx,正确做法是把后续工作挪进 withSession 回调、用回调里传进来的新 ctx。这段提示在 loader.ts 和 runner.ts 里各写了一份,说明它是被踩过的坑。
四、包装层拦在中间到底为了什么
到这一步,扩展注册的工具还只是一个 RegisteredTool(定义加来源信息),不是宿主能直接执行的东西。wrapper.ts 全文不到 50 行,做的就是这个转换,但它拦在中间的理由不止「转个类型」这么简单。
文件头的注释先把边界划清楚了:这些包装器只负责适配工具执行,让扩展工具拿到 runner 的上下文;工具调用与工具结果的拦截,是 AgentSession 通过 agent-core 的 hook 做的,不在这里。这句话很重要——如果你想改 tool_call 或 tool_result 的行为,翻 wrapper.ts 是找错地方了。
它实际做的三件事是:
第一,惰性注入上下文。wrapToolDefinition(registeredTool.definition, () => runner.createContext()) 传进去的是一个工厂函数而不是一个现成的 ctx。为什么必须惰性?看 runner.createContext() 的实现就明白了——返回的对象里 ui、mode、cwd、model、sessionManager 全是 getter,每次读取都先 assertActive() 再从 runner 当前状态取值。如果包装时就把 ctx 求值好存下来,bindCore 和 UI 绑定之后的变化就再也传不进工具了。同样的考虑在 createCommandContext() 里被写成了注释:那里用 Object.defineProperties 加 getOwnPropertyDescriptors 来复制,而不是对象展开,因为展开会把 getter 当场求值一次并把旧值冻死,绕过过期检查。
第二,抹平内置工具和扩展工具。agent-session.ts 里两次调用 wrapRegisteredTools:一次包扩展工具,一次包内置工具定义(内置那批用 <builtin:名字> 形式的合成来源信息)。之后两批合进同一个注册表,内置先入、扩展后入,同名时扩展覆盖内置。也就是说「扩展能替换内置工具」这个能力,不是某个特判实现的,而是这层统一包装的自然结果。
第三,捕捉执行过程中新激活的工具。包装后的 execute 会在调用前后各取一次 runner.getActiveTools()。如果调用前活跃的工具没有全部保留在调用后的列表里(说明工具集被大改而不是被追加),就原样返回结果不做处理;否则算出新增的名字,合并进结果的 addedToolNames。这解决的是一类具体问题:某个扩展工具执行时动态开启了别的工具,宿主得知道这件事,否则模型不会知道自己多了什么能力。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 发现与路径去重 | 扫三处来源、按三条规则找入口、按绝对路径去重 | packages/coding-agent/src/core/extensions/loader.ts | 扩展放对了却没被加载时 |
| jiti 载入与依赖解析 | Node 走 alias、Bun 二进制走 virtualModules,导入并校验工厂函数 | 同上文件 loadExtensionModule | 扩展里 import 某个包报解析失败时 |
| ExtensionAPI | 提供注册方法(写入 Extension)与动作方法(转发运行期) | 同上文件 createExtensionAPI | 写扩展时你直接用的那个 pi 对象 |
| ExtensionRuntime | 加载期是抛错桩与待办队列,绑定后被换成真实实现 | 同上文件 createExtensionRuntime | 撞上 runtime not initialized 时 |
| ExtensionRunner | 持有扩展列表,分发事件、解析命令名与快捷键、造 ctx | packages/coding-agent/src/core/extensions/runner.ts | 事件没触发、命令重名、快捷键被吞时 |
| 工具包装层 | 惰性注入 ctx、统一内置与扩展工具、回传 addedToolNames | packages/coding-agent/src/core/extensions/wrapper.ts | 工具执行拿不到上下文时 |
| 定义到工具的转换 | 把 ToolDefinition 转成 AgentTool,ctx 参数可被外部覆盖 | packages/coding-agent/src/core/tools/tool-definition-wrapper.ts | 想搞清楚 ctx 从哪来时 |
五、事件分发的语义不是一套,是七八套
很多人默认「事件总线嘛,广播一圈完事」。runner.ts 里不是这样,每类事件的合并语义都单独写了一个方法,差别很大,直接影响你的处理器该怎么返回值:
通用的 emit() 遍历所有扩展的所有处理器,异常会被捕获转成 emitError;只有 session_before_* 这四个事件的返回值会被采纳,而且一旦某个返回 cancel 就立刻短路返回。emitToolCall() 的返回值里 block 为真也直接短路,但它没有 try/catch,处理器抛出的异常会向上冒泡,和其余事件的「吞掉记错误」不是一个策略。emitInput() 是链式的:返回 handled 立刻短路,返回 transform 则把文本和图片替换掉传给下一个处理器。emitContext() 先对消息数组做 structuredClone 再往下传,处理器改的是副本。emitMessageEnd() 会校验返回的消息 role 必须和原消息一致,不一致就记一条错误并跳过。emitBeforeProviderHeaders() 靠处理器原地修改 headers,注释明说返回值被忽略。project_trust 干脆没放在类里,而是 runner.ts 顶层的独立函数 emitProjectTrustEvent:返回 undecided 就继续问下一个,第一个给出明确答复的胜出。
命名冲突的处理也在这一层。resolveRegisteredCommands() 发现同名命令时会生成 名字:序号 形式的调用名;工具则是 getAllRegisteredTools() 里「同名先注册者胜出」。快捷键最复杂:runner.ts 顶部有一份保留键位清单,包含中断、清屏、退出、切模型、提交输入等,扩展想抢这些会被直接跳过并打一条 warning;抢非保留的内置键位则允许覆盖,但同样打 warning;两个扩展抢同一个键,后注册的胜出,还是 warning。这些 warning 在没有 UI 的模式下会直接 console.warn 出来。关于事件与工具边界怎么设计得更稳,可以对照工具设计的通用取舍一起看。
六、边界与代价:这套设计放弃了什么
放弃了进程隔离。 扩展是被 jiti 载进同一个进程直接执行的,没有沙箱,没有权限声明清单。扩展能拿到 exec 直接跑命令,能拿到 sessionManager 和 modelRegistry。这意味着安装一个第三方扩展,等价于在你的机器上跑一段有完整权限的代码。项目里有 project_trust 事件这类信任判定的接口,但那是「要不要信任这个项目目录」,不是「限制这个扩展能做什么」。权限该怎么收,见最小权限的设计方法。
放弃了扩展之间的强隔离。 所有扩展共享同一个 ExtensionRuntime 实例,共享同一个 flag 值表。工具重名、命令重名、快捷键重名都只是打日志然后按固定优先级选一个,不会报错更不会阻止加载。扩展装多了之后,谁覆盖了谁需要你自己看 warning。
放弃了发现的灵活性。 目录扫描明确不递归超过一层,复杂结构必须写 package.json 清单。这是拿灵活性换启动确定性和扫描开销。
它明确不管的事: 工具调用和结果的拦截不在包装层(在 AgentSession 的 hook 里);扩展的版本兼容性不在加载器里做校验,加载器只管拿到的东西是不是函数;pi.exec 只是把命令、参数和选项转给 execCommand(选项类型 ExecOptions 只有 signal、timeout、cwd 三项,cwd 缺省时回落到加载时的 cwd),重试、限流这类策略这一层不提供,要就自己在扩展里写。
不适用的场景也很清楚: 你要跑一个别人写的、来源不明的扩展,或者要在多租户环境里让不同用户装不同扩展,这套同进程模型不合适——那种场景本来就该用独立进程加协议的方案。反过来,如果你要的是深度定制自己的 Agent 行为,同进程带来的能力上限和调试便利是协议方案给不了的。
七、上手与避坑清单
扩展没被发现,先数目录层级。 会踩是因为你按常见插件系统的习惯建了 extensions/my-ext/src/index.ts。发现规则只认直接文件、子目录下的 index.ts/index.js、以及 package.json 里 pi 字段声明的路径,不再往下递归。避法:要么把入口提到子目录第一层,要么老老实实写清单。
清单里的路径写错不会报错。 会踩是因为清单里的每个路径都要过一次存在性检查,不存在就被丢掉;全丢光了还会回落去找 index.ts,于是现象是「加载了一个不是我想要的入口」或者「什么都没加载」。避法:改完清单先确认路径能解析到真实文件,别靠报错提醒你。
在工厂函数里调动作方法必然抛错。 会踩是因为工厂函数看起来就是「初始化的地方」,顺手就想发条欢迎消息,但那时运行期还是一堆抛错桩。避法:工厂体内只做注册,要执行的动作挪进事件处理器、命令处理器或工具的 execute 里。
捕获 ctx 存成模块变量,迟早报 stale。 会踩是因为存起来复用看上去能省事,但会话替换或重载之后旧 ctx 会被 invalidate,之后任何调用都抛异常。避法:每次需要时从处理器参数里现取;newSession/fork/switchSession 之后的收尾工作,放进 withSession 回调并使用回调传进来的那个 ctx。
只在 Node 下验证过就发版,Bun 二进制里可能挂。 会踩是因为两种运行方式的依赖解析路径完全不同:一边是 alias 指向真实文件,一边是虚拟模块表加 tryNative: false,表里没列的包在二进制里就解析不到。避法:依赖尽量收敛到表里已有的那几个;确实要引第三方依赖,两种方式都跑一遍再发。
别在新扩展里用旧包名前缀。 会踩是因为老示例还写着旧前缀,而映射表里确实留着它。避法:新代码统一用 @earendil-works/ 前缀;同时留意 pi-ai 根路径当前解析到的是 compat 入口,注释已写明 compat 将来会被移除,别把扩展建在只有 compat 才有的 API 上。
快捷键被吞不会报错,只会打 warning。 会踩是因为注册的键位撞上了保留清单里的中断、退出、切模型这些。避法:注册前先看 runner.ts 顶部那份保留清单,调试时留意启动阶段的 warning,无 UI 模式下它会直接打出来。相关排查思路可参考Agent 框架调试方法。
事件处理器的返回值语义各不相同。 会踩是因为你按 emit 的印象去写 before_provider_headers 的处理器,返回一个新对象——而那个方法明说返回值被忽略,只认原地修改。避法:动手前先去 runner.ts 找对应的 emitXxx 方法,看它怎么合并结果。
收束
这套加载器真正的骨架只有三段:发现(三处来源、三条规则、一次去重)→ 载入(jiti,两套依赖解析,工厂校验,失败隔离)→ 绑定(加载期抛错桩与待办队列,bindCore 之后换成真实实现)。工具包装层拦在中间,是为了让上下文保持惰性、让内置工具与扩展工具走同一条路、让执行期新开的工具能被回传,而不是为了拦截调用本身。
接着往下读的话,建议按这个顺序:先看 types.ts 里 ExtensionAPI 和 ExtensionRuntime 的完整签名,那是扩展作者的实际接口面;再看 runner.ts 里 bindCore 和 createContext 这两个方法,宿主与扩展的所有连接点都在那;最后看 core/resource-loader.ts,那里能看到带缓存的加载在真实启动流程中是怎么被调度的。
自检三问:你的动作方法有没有跑在工厂函数体内?你有没有把 ctx 存起来跨会话复用?你的扩展在 Node 和 Bun 二进制两种方式下都跑过吗?这三条过了,剩下的多半就是业务问题,不是加载器问题了。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的技能机制 和 开源编程 Agent pi 的安全边界。