oh-my-pi 扩展加载机制全解析:从路径发现到模块执行的完整链路
2026/9/12 15:29:08 网站建设 项目流程

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 并执行工厂函数,最终返回三样东西:

  1. 已加载的扩展定义(extension definitions)集合;
  2. 按路径收集的加载错误(单个失败不会中断整体加载);
  3. 一个共享的扩展运行时对象(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/prehooks/post目录(discoverHooksInPackageRoot,见 loader.ts)。

3) 已安装插件的扩展条目

接着,discoverExtensionPaths()通过getAllPluginExtensionPaths(cwd)追加已启用的已安装插件的扩展入口(见 loader.ts)。

插件扩展条目来自包的omp.extensions/pi.extensions清单(包括启用的 feature 条目)。已安装插件的清单解析接受.ts.js.mjs.cjs四种后缀;若清单条目指向目录,则识别index.tsindex.jsindex.mjsindex.cjs,扩展目录展开同样使用这四种后缀。这比原生与配置目录的自动扫描(仅限.ts.js)更宽。

4) 显式配置的路径

最后追加显式配置路径。在主会话启动路径(sdk.ts)中,配置路径来源有两类:

  1. CLI 传入路径--extension/-e,以及同样被视为扩展路径的--hook
  2. 合并后的 settingsextensions数组

配置文件位置:

  • 用户级:当前 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

两者的行为语义完全一致,但拆分来看:

  • SDKdisableExtensionDiscovery=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.tsfoo
  • /x/bar/index.tsbar

配置示例:

disabledExtensions: - extension-module:foo

在 loader 实现中,addPaths会在入列前用isDisabledName(getExtensionNameFromPath(extPath))做过滤(见 loader.ts)。

禁用其他能力的单项

disabledExtensions并不仅限于扩展模块。每个定义了toExtensionId的能力都会向同一份列表贡献 id,加载时会在条目进入会话前将其过滤掉。

例如上下文文件使用context-file:<level>:<basename>格式,其中<level>userproject

disabledExtensions: - context-file:user:CLAUDE.md

该 id 不携带目录、不含深度信息,因此一个project条目会禁用发现遍历到达的每一层深度下的同名文件(详见 docs/context-files.md)。

路径与入口解析

路径规范化

对于显式配置路径,依次执行:

  1. 规范化 Unicode 空格与支持的路径简写(包括file://@/absolute/path,以及绝对/相对路径前多余的:);
  2. 展开~
  3. 相对路径基于当前cwd解析;
  4. 拒绝内部local://协议——它必须由协议处理器解析,不能当作文件系统路径。

配置路径是文件时

直接作为模块入口候选使用。显式.ts.js.mjs.cjs文件均受支持。

配置路径是目录时

解析顺序(实现见resolveExtensionEntries,loader.ts):

  1. 该目录下package.jsonomp.extensions(或遗留pi.extensions)→ 使用声明的条目;
  2. index.ts
  3. index.js
  4. 否则扫描一层扩展条目:
    • 直接*.ts/*.js文件;
    • 子目录的index.ts/index.js
    • 子目录package.jsonomp.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: truehidden: false
  • 显式配置目录扫描(loader.ts 内)使用readdir规则,不应用 gitignore 过滤

加载顺序与优先级

discoverAndLoadExtensions()构建一条有序列表后统一交给loadExtensions()。顺序为:

  1. 原生自动发现的模块;
  2. 发现的 JS/TS Hook 工厂;
  3. 已安装插件的扩展条目;
  4. 显式配置路径(按提供顺序)。

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)加载:

  1. realpath 解析 + 动态 import 加?mtime缓存破坏符:入口先解析真实路径(兼容 macOS/var/private/varbun link/pnpm 安装),再用带?mtime=<tag>的后缀动态 import。这样编辑过源码后重新加载能拿到新内容;POSIX 下使用裸文件系统路径而非file://前缀,因为 Bun 会把?mtime视为模块身份的一部分,而file://上的 query string 会被忽略、导致读到陈旧源码。
  2. 图级 mtime 传播:从 16.3.7 起,同一个 mtime 标签会传播到扩展所属依赖图中的每一个模块——相对.//../导入、包imports别名(#alias/*)、以及扩展局部的裸依赖——通过图级onLoad重写实现。因此同进程内重新导入时,整个依赖图(而非仅入口文件)都能感知编辑。而 host 解析的重写(遗留 pi 包说明符、TypeBox shim)保持不带标签的file://URL,因为它们指向进程内 host 代码,重载之间不会变化。
  3. 作用域化的 BunonLoadHook 重写遗留说明符installLegacyPiSpecifierShim()(legacy-pi-compat.ts)注册的Bun.plugin在求值前把@mariozechner/*@earendil-works/*及裸@sinclair/typebox重写到 host 内置副本上。

遗留兼容的完整链路还包括两个 shim:

  • 移到@oh-my-pi/pi-catalog/models的 catalog 符号(calculateCostmodelsAreEqualgetBundledProviders,以及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的所有动作方法(sendMessagesendUserMessageappendEntrysetModel等)在初始化前都是抛错桩(见 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询