oh-my-pi 扩展加载机制全解析:从路径发现到模块执行的完整链路
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
导读
oh-my-pi(⌥ Coding agent with the IDE wired in)在启动时通过一套统一的扩展加载子系统,将原生扫描、Hook 工厂、已安装插件与显式配置四类 TypeScript/JavaScript 模块入口汇总为一条有序加载管线,交给 Bun 运行时导入并执行其工厂函数。本文以 docs/extension-loading.md 为主线,结合packages/coding-agent中的真实实现,系统讲解扩展从「被发现」到「被绑定」再到「被运行」的完整生命周期,并给出可直接落地的用户级与项目级目录布局示例。
这个子系统做了什么
扩展加载子系统的职责非常聚焦:构建一份模块入口文件清单,用 Bun 逐个 import 并执行工厂函数,最终返回三样东西:
- 已加载的扩展定义(extension definitions)集合;
- 按路径收集的加载错误(单个失败不会中断整体加载);
- 一个共享的扩展运行时对象(extension runtime),供后续
ExtensionRunner使用。
核心实现位于 loader.ts,对外出口在 index.ts,加载完成后的运行时/事件执行则交给 runner.ts。值得注意的是,loader.ts顶部的导入注释明确写着一个约束:运行时自引用(PiCodingAgent)只能在 loader 函数内部解引用,以避免index.ts的循环依赖(见 loader.ts)。
从入口函数的组装关系可以清晰看到这个子系统的骨架:
// loader.ts export async function discoverAndLoadExtensions( configuredPaths: string[], cwd: string, eventBus?: EventBus, disabledExtensionIds?: string[], options: DiscoverExtensionPathOptions = {}, ): Promise<LoadExtensionsResult> { const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, options); return loadExtensions(paths, cwd, eventBus); }(loader.ts)——先做文件系统扫描得到路径,再做模块导入与工厂绑定,两阶段分离的设计也允许子代理复用父会话已经「预备」(prepared)好的工厂,只重新绑定自己的运行时。
扩展的四个来源
discoverAndLoadExtensions()会按固定顺序向discoverExtensionPaths()汇聚四类路径(实现见 loader.ts)。
1) 原生自动发现的扩展模块
第一步通过 capability API 加载extension-module能力项,但只保留 provider 为native的条目。loader 注释里说明了原因:该能力还有 claude/codex/gemini/opencode 等 provider,它们的条目在这里本来就会被丢弃,直接按 provider 过滤可以跳过四次无关目录扫描(见 loader.ts)。
原生extension-module的发现来源有三个:
- 项目目录:
<cwd>/.omp/extensions - 用户目录:当前 agent 目录下的
extensions/(默认~/.omp/agent/extensions) - 原生遗留 settings JSON 条目:
<cwd>/.omp/settings.json#extensions与当前 agent 目录的settings.json#extensions
这里有两个重要的边界语义:
- 项目根是 cwd-only 的:项目根即原生 provider 的
.omp目录,只扫描 cwd 本身,不会向上遍历祖先目录; - 用户根跟随 profile 与环境变量:用户根通过
getAgentDir()解析,因此omp --profile <name>下会变成~/.omp/profiles/<name>/agent/extensions,并且尊重PI_CODING_AGENT_DIR环境变量的覆盖(详见 docs/config-usage.md)。
遗留兼容方面:.pi仍然被pi.extensions清单与项目覆盖查找接受,但.pi/extensions不再是本子系统的原生根目录。原生 provider 的具体实现可参考 builtin.ts:它先Promise.all并行扫描各配置目录的extensions/子目录与settings.json,再对 settings 中声明的路径做「目录还是文件」的二选一解析,目录走discoverExtensionModulePaths扫描,文件直接作为模块条目。
2) 发现的 JS/TS Hook 工厂
原生自动发现之后,discoverExtensionPaths()还会把hook能力中入口路径是.ts/.js文件的 Hook 工厂追加进来,让它们走同一条模块加载管线。
关键差异:Hook 能力加载时已经应用了自己专属的 disabled id 过滤,所以这些路径不会再被disabledExtensions中的扩展模块名额外过滤一次。在 loader 中对应loadCapability<Hook>(hookCapability.id, ...)后按文件后缀过滤的逻辑(见 loader.ts);而当非 ambient(非环境性)发现时,还会扫描显式配置包根的hooks/pre、hooks/post目录(discoverHooksInPackageRoot,见 loader.ts)。
3) 已安装插件的扩展条目
接着,discoverExtensionPaths()通过getAllPluginExtensionPaths(cwd)追加已启用的已安装插件的扩展入口(见 loader.ts)。
插件扩展条目来自包的omp.extensions/pi.extensions清单(包括启用的 feature 条目)。已安装插件的清单解析接受.ts、.js、.mjs、.cjs四种后缀;若清单条目指向目录,则识别index.ts、index.js、index.mjs、index.cjs,扩展目录展开同样使用这四种后缀。这比原生与配置目录的自动扫描(仅限.ts与.js)更宽。
4) 显式配置的路径
最后追加显式配置路径。在主会话启动路径(sdk.ts)中,配置路径来源有两类:
- CLI 传入路径:
--extension/-e,以及同样被视为扩展路径的--hook; - 合并后的 settings
extensions数组。
配置文件位置:
- 用户级:当前 agent 目录的
config.yml(默认~/.omp/agent/config.yml;--profile <name>时为~/.omp/profiles/<name>/agent/config.yml;可用PI_CODING_AGENT_DIR覆盖 agent 目录); - 项目/原生 settings 能力:
<cwd>/.omp/config.yml与<cwd>/.omp/settings.json。
原生扩展模块发现还会读取遗留 JSON 扩展列表:当前 agent 目录的settings.json(默认~/.omp/agent/settings.json)与<cwd>/.omp/settings.json。
两种格式的配置示例(完整继承自原文档):
# ~/.omp/agent/config.yml extensions: - ~/my-exts/safety.ts - ./local/ext-pack{ "extensions": ["./.omp/extensions/my-extra"] }启用与禁用控制
整体禁用扩展发现
- CLI:
--no-extensions - SDK 选项:
disableExtensionDiscovery
两者的行为语义完全一致,但拆分来看:
- SDK:
disableExtensionDiscovery=true时,ambient(环境性)扩展工厂被排除,但additionalExtensionPaths仍正常解析(包括带package.json#omp.extensions的包目录); - CLI:
--no-extensions遵循同样的显式契约——显式的-e/--extension与--hook路径照常加载,且只有显式命名的扩展包中的兄弟能力根(sibling capability roots)仍被纳入;项目/用户的extensions:settings 与已安装的 OMP 扩展包则从兄弟面中排除。
源码层面的对应逻辑清晰可见:discoverExtensionPaths()的options.ambient开关直接控制第 1/2/3 阶段是否执行,第 4 阶段(显式配置)无条件执行(见 loader.ts)。
需要强调的是:这个开关只管辖扩展工厂与 OMP 扩展包的兄弟根,不是整个进程的能力隔离开关。Skills、MCP 服务器、tools、prompts、rules 等由其他发现子系统持有的能力,仍然拥有各自的启用/禁用控制。
禁用指定的扩展模块
disabledExtensions设置按扩展 id 格式过滤:
extension-module:<derivedName>derivedName基于入口路径计算(getExtensionNameFromPath),例如:
/x/foo.ts→foo/x/bar/index.ts→bar
配置示例:
disabledExtensions: - extension-module:foo在 loader 实现中,addPaths会在入列前用isDisabledName(getExtensionNameFromPath(extPath))做过滤(见 loader.ts)。
禁用其他能力的单项
disabledExtensions并不仅限于扩展模块。每个定义了toExtensionId的能力都会向同一份列表贡献 id,加载时会在条目进入会话前将其过滤掉。
例如上下文文件使用context-file:<level>:<basename>格式,其中<level>为user或project:
disabledExtensions: - context-file:user:CLAUDE.md该 id 不携带目录、不含深度信息,因此一个project条目会禁用发现遍历到达的每一层深度下的同名文件(详见 docs/context-files.md)。
路径与入口解析
路径规范化
对于显式配置路径,依次执行:
- 规范化 Unicode 空格与支持的路径简写(包括
file://、@/absolute/path,以及绝对/相对路径前多余的:); - 展开
~; - 相对路径基于当前
cwd解析; - 拒绝内部
local://协议——它必须由协议处理器解析,不能当作文件系统路径。
配置路径是文件时
直接作为模块入口候选使用。显式.ts、.js、.mjs、.cjs文件均受支持。
配置路径是目录时
解析顺序(实现见resolveExtensionEntries,loader.ts):
- 该目录下
package.json带omp.extensions(或遗留pi.extensions)→ 使用声明的条目; index.ts;index.js;- 否则扫描一层扩展条目:
- 直接
*.ts/*.js文件; - 子目录的
index.ts/index.js; - 子目录
package.json带omp.extensions/pi.extensions。
- 直接
约束与规则(均与源码行为一一对应):
- 不递归:发现最多深入到一层子目录,复杂包必须使用
package.json清单(discoverExtensionsInDir的注释明确写着 "No recursion beyond one level",见 loader.ts); - 清单声明的
extensions条目相对于该包目录解析; - 声明条目仅在文件存在/可访问时才被纳入(
resolveExtensionEntries中对缺失路径continue跳过); */index.{ts,js}同时存在时,TypeScript 优先于 JavaScript(源码中先statindex.ts 再 index.js);- 符号链接被视为合法的文件/目录候选(发现循环中
entry.isSymbolicLink()与普通文件/目录同等对待)。
忽略行为因来源而异
- 原生自动发现(discovery helpers 中的
discoverExtensionModulePaths)使用原生 glob,带gitignore: true与hidden: false; - 显式配置目录扫描(loader.ts 内)使用
readdir规则,不应用 gitignore 过滤。
加载顺序与优先级
discoverAndLoadExtensions()构建一条有序列表后统一交给loadExtensions()。顺序为:
- 原生自动发现的模块;
- 发现的 JS/TS Hook 工厂;
- 已安装插件的扩展条目;
- 显式配置路径(按提供顺序)。
在sdk.ts中,配置顺序为:CLI 附加路径 → settingsextensions。
去重规则:
- 基于绝对路径;
- 先到先得:首次出现的路径获胜;
- 后续重复项被忽略。
由此可以推导出一个重要结论:同一个模块路径如果同时被自动发现和显式配置,它只会在第一个位置(自动发现阶段)被加载一次。源码对应addPath中的seenSet 去重(见 loader.ts)。
性能上的一个细节值得留意:loadExtensions()中模块导入(cold-start 的主要成本——文件 I/O 加模块求值)是跨扩展并发执行的(Promise.all),而工厂绑定则按原始路径顺序串行执行(bindPreparedExtensions的 for 循环),从而保证注册语义(同名校验的 last-wins 冲突、共享 runtime 的 flag 默认值)是确定性的(见 loader.ts)。
模块导入与工厂契约
每个候选路径都经由loadLegacyPiModule()(legacy-pi-compat.ts)加载:
- realpath 解析 + 动态 import 加
?mtime缓存破坏符:入口先解析真实路径(兼容 macOS/var→/private/var、bun link/pnpm 安装),再用带?mtime=<tag>的后缀动态 import。这样编辑过源码后重新加载能拿到新内容;POSIX 下使用裸文件系统路径而非file://前缀,因为 Bun 会把?mtime视为模块身份的一部分,而file://上的 query string 会被忽略、导致读到陈旧源码。 - 图级 mtime 传播:从 16.3.7 起,同一个 mtime 标签会传播到扩展所属依赖图中的每一个模块——相对
.//../导入、包imports别名(#alias/*)、以及扩展局部的裸依赖——通过图级onLoad重写实现。因此同进程内重新导入时,整个依赖图(而非仅入口文件)都能感知编辑。而 host 解析的重写(遗留 pi 包说明符、TypeBox shim)保持不带标签的file://URL,因为它们指向进程内 host 代码,重载之间不会变化。 - 作用域化的 Bun
onLoadHook 重写遗留说明符:installLegacyPiSpecifierShim()(legacy-pi-compat.ts)注册的Bun.plugin在求值前把@mariozechner/*、@earendil-works/*及裸@sinclair/typebox重写到 host 内置副本上。
遗留兼容的完整链路还包括两个 shim:
- 移到
@oh-my-pi/pi-catalog/models的 catalog 符号(calculateCost、modelsAreEqual、getBundledProviders,以及getModel/getModels别名)由遗留 pi-ai shim(src/extensibility/legacy-pi-ai-shim.ts)重新导出; - 遗留
@oh-my-pi/pi-coding-agent导入(包括DefaultResourceLoader)解析到src/extensibility/legacy-pi-coding-agent-shim.ts中的兼容 loader。
工厂的选择逻辑在getExtensionFactory():模块本身是函数则用它,否则用module.default(见 loader.ts)。工厂必须是函数(ExtensionFactory),可返回void或 promise,加载过程会 await 它完成后才继续下一个路径。若导出不是函数,该路径以结构化错误失败,加载继续。
绑定阶段的另一个细节是runExtensionFactory()中的provider 注册回滚:执行工厂前先对runtime.pendingProviderRegistrations做检查点快照,工厂抛错时恢复完整注册队列——因为前一个扩展可能 unregister 了更早扩展排队的条目(见 loader.ts)。
失败处理与隔离
加载期间
每个扩展路径的失败被捕获为{ path, error },不会阻止其他路径加载。常见失败场景:
- import 失败 / 文件缺失;
- 无效工厂导出(非函数);
- 工厂执行时抛出异常。
在源码中,importExtensionModule()与bindExtension()各自 try/catch 并返回结构化错误(见 loader.ts),bindPreparedExtensions收集到errors数组后继续循环。
运行时隔离模型
- 扩展不做沙箱隔离(同一进程/运行时);
- 它们共享一个
EventBus与一个ExtensionRuntime实例; - 加载期间,runtime 的动作方法会故意抛出
ExtensionRuntimeNotInitializedError——动作接线要等ExtensionRunner.initialize()之后才发生。源码中ExtensionRuntime的所有动作方法(sendMessage、sendUserMessage、appendEntry、setModel等)在初始化前都是抛错桩(见 loader.ts)。
加载之后
事件经由ExtensionRunner运行时,handler 异常会被捕获并作为扩展错误发出,而不是让 runner 循环崩溃。runner 还会为每个事件设置独立的处理预算:通用事件默认 30 秒(EXTENSION_HANDLER_TIMEOUT_MS),而session_shutdown使用独立的 2 秒短上限(SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS),因为它是 fire-and-forget 的清理逻辑,挂起的 handler 绝不应拖住用户的 Ctrl+C //exit(见 runner.ts)。
最小目录布局示例
用户级
~/.omp/agent/ config.yml extensions/ guardrails.ts audit/ index.ts项目级
<repo>/ .omp/ settings.json extensions/ checks/ package.json lint-gates.ts其中checks/package.json使用清单声明扩展入口:
{ "omp": { "extensions": ["./src/check-a.ts", "./src/check-b.js"] } }遗留清单键仍然接受:
{ "pi": { "extensions": ["./index.ts"] } }小结
oh-my-pi 的扩展加载是一条「发现 → 排序去重 → 并发导入 → 顺序绑定 → 运行时接线」的清晰流水线:原生.omp目录、Hook 工厂、插件清单与显式配置四路汇流,disabledExtensions提供模块级与能力级两种粒度的禁用;路径解析严格区分文件/目录与清单声明;?mtime缓存破坏与图级重写保证开发期热重载;遗留 pi 说明符 shim 让旧版插件无缝运行。深入 loader.ts、legacy-pi-compat.ts 与 runner.ts,可以进一步掌握每个阶段的实现细节,为编写健壮的扩展或排查加载问题打下基础。
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考