返回课程首页

23

Extension 的完整加载过程

走读发现、模块加载、Factory 注册和 Runtime 绑定。

源码基线:Pi v0.82.0 · commit 518855d

把示例 block-destructive-shell.ts.example 复制成 Extension .ts 文件并放进项目目录后, 它为什么能在 Tool 执行前工作?

从文件到运行行为,至少跨越四层:

Package/Settings 解析资源
→ Loader 导入 Module 并执行 Factory
→ ExtensionRunner 收集注册结果
→ AgentSession 绑定 Core Actions 与 Tool Registry

1. 总览:文件不是 Runtime

flowchart LR
    P["Extension Path"] --> M["loadExtensionModule"]
    M --> F["Extension Factory"]
    F --> A["ExtensionAPI"]
    A --> E["Extension<br/>handlers/tools/..."]
    E --> R["ExtensionRunner"]
    R --> B["AgentSession Bindings"]

每个阶段的产物不同:

阶段 输入 输出
资源解析 Settings、Package、CLI Path 有序 Extension Paths + Metadata
Module 加载 文件绝对路径 Factory Function
Factory 执行 Factory + API Extension 注册容器
Runtime 组装 Extensions + Shared Runtime ExtensionRunner
Core 绑定 Runner + AgentSession 可执行 Actions、Events、Tools

2. 两条“发现”路径不要混为一谈

Loader 导出 discoverAndLoadExtensions()。它直接扫描:

  1. 项目 .pi/extensions
  2. 用户 agentDir/extensions
  3. 显式配置路径。

但当前 coding-agent 的完整 ResourceLoader.reload() 路径先调用 PackageManager.resolve(), 把 Settings、Package、Trust、Enable/Disable 和 CLI Source 解析成资源,再调用 loadExtensionsCached()

flowchart TD
    A["直接 Loader Helper"] --> D["discoverAndLoadExtensions"]
    D --> L["loadExtensions"]

    S["实际 ResourceLoader"] --> P["PackageManager.resolve"]
    P --> X["有序 Enabled Paths"]
    X --> C["loadExtensionsCached"]

前者适合独立使用 Loader;后者才保留完整 Package SourceInfo 和项目配置语义。

3. Loader Helper 怎样扫描

discoverExtensionsInDir() 只看一层:

extensions/
├── one.ts             ← 加载
├── two.js             ← 加载
├── three/
│   └── index.ts       ← 加载
└── package/
    └── package.json   ← 读取 pi.extensions

规则:

  • 直接 .ts / .js 文件;
  • 子目录中的 index.ts 优先于 index.js
  • 子目录 package.jsonpi.extensions 优先;
  • 不递归无限扫描;
  • 复杂目录必须通过 Manifest 声明入口。

不存在或无法读取的自动目录返回空列表,而不是抛出终止整个加载的异常。

4. PackageManager 的生产扫描

完整 Resource Loader 使用 collectAutoExtensionEntries()。它比 Loader Helper 多处理:

  • 先检查 Extension Root 自身的 package.json#pi.extensionsindex.ts/index.js
  • 若 Root 没有入口,再扫描一层直接文件和子目录入口;
  • 读取 .gitignore.ignore.fdignore
  • 跳过 Dot Directory 与 node_modules
  • 对 Symbolic Link 先 stat,再判断 File/Directory;
  • 目录中直接发现的文件只接收 .ts.js。Manifest 中的 pi.extensions 入口只检查是否 存在,不按扩展名过滤;只有至少解析出一个存在入口时才短路,否则继续回退到 index.ts/index.js
flowchart TD
    D["extension root"] --> R{"root manifest/index?"}
    R -->|有| E["只返回声明入口"]
    R -->|无| I["建立 ignore matcher"]
    I --> S["扫描一层"]
    S --> F["直接 ts/js"]
    S --> C["子目录 manifest/index"]

所以直接 Helper 的规则用于理解 Loader 自包含能力,PackageManager 的规则才是当前完整产品 路径。

5. Resource Loader 先建立加载顺序

ResourceLoader.reload()

  1. 必要时清 Extension Module Cache;
  2. 处理 Project Trust Bootstrap;
  3. Reload Settings;
  4. 让 Package Manager 解析资源;
  5. 过滤 Disabled Resource;
  6. 解析 CLI Additional Extension Sources;
  7. 合并成有序 Path;
  8. 调用 loadFinalExtensionSet()

noExtensions 关闭常规 Package/Settings/Auto Resource,但临时 CLI/Additional Extension Path 与宿主传入的 Inline Factory 仍可进入。这些是显式调用者选择的例外,不要把它理解成 绝对“什么 Extension 都不加载”。

6. 谁排在前面

PackageManager 用 Rank 排序,Resource Loader 再把 CLI/Additional Path 放到最前。最终 优先级从高到低是:

  1. CLI / Additional Extension Source;
  2. Project Settings;
  3. Project Auto-discovered;
  4. User Settings;
  5. User Auto-discovered;
  6. Package Resource;
  7. Inline Factory 最后追加。

Tool/Flag 跨 Extension First Wins,因此这个顺序会决定 Winner。相同 Path 还会按 Canonical Path 去重。

7. Project Trust 为什么需要两阶段

在需要询问 Trust 时,Resource Loader 先把项目标记为不可信,加载:

  • 用户/全局 Extension;
  • 临时 CLI Extension;
  • Inline Factory。

它们可以参与 project_trust 决策。用户确认后,再加载项目 Extension。

第二阶段复用已经加载的路径和同一个 Runtime,只导入剩余 Path,最后恢复目标顺序。这避免 全局 Extension Factory 为一次 Trust 决策重复执行。

8. Module 怎样被导入

loadExtensionModule() 为每个 Path 创建 Jiti:

const module = await jiti.import(extensionPath, {
  default: true,
});

Jiti 允许直接加载 TypeScript。随后检查默认导出是否为 Function;不是则返回加载错误:

Extension does not export a valid factory function

Node/开发环境通过 Alias 解析 Pi Package;编译后的 Bun Binary 使用 Virtual Modules。 课程不引入 TUI 使用方式,但 Loader 的兼容模块表仍包含该包,这是源码事实。

9. Cache 到底缓存什么

extensionCache 缓存的是:

resolved extension path → ExtensionFactory

不是已经执行完成的 Extension Object。

Cache Token 同时包含:

  • Resolved CWD;
  • Cache Generation。

CWD 变化会清 Cache;clearExtensionCache() 也会增加 Generation。Jiti 自身配置 moduleCache: false,外层 Cache 决定是否复用 Factory。

10. SourceInfo 何时出现

createExtension() 先产生 Synthetic SourceInfo:

  • 普通文件默认 Source local
  • <inline:...> 一类虚拟路径从标签推断 Source;
  • 普通文件 BaseDir 是 Resolved Path 的 Directory。

完整 Resource Loader 随后调用 applyExtensionSourceInfo(),用 Package Manager 收集的 Metadata 覆盖/补全来源。这就是为什么仅看 Loader 阶段的 Synthetic Source,可能还看不到 最终 npm、Git、Scope 和 Origin。

11. 创建空 Extension 容器

每个文件对应一个 Extension Object:

path / resolvedPath / sourceInfo
handlers: Map
tools: Map
commands: Map
flags: Map
shortcuts: Map
messageRenderers: Map
entryRenderers: Map

Factory 调用的 pi.on()registerTool() 等,本质是在这些 Map 中写入注册结果。

同一个 Extension 内重复注册同名 Tool、Command 或 Flag,会由各自 Map 后写覆盖前写; Handler 则 Push 到 Array,因此同一 Event 可以有多个 Handler。Flag 有一项细节:Definition 会后写覆盖,但 Default Value 只在 Runtime 还没有这个 Name 时写入,所以重复 Flag 可能呈现 “后一个 Definition + 第一个 Default”。

12. 创建受控 ExtensionAPI

createExtensionAPI() 把方法分成两类:

Registration

直接写当前 Extension:

on / registerTool / registerCommand / registerFlag / ...

Action

委托给 Shared ExtensionRuntime

sendMessage / setActiveTools / setModel / appendEntry / ...

在 Core 尚未绑定时,大多数 Action 是 Throwing Stub。Factory 阶段可以注册能力,但不能 假设已经拥有完整 AgentSession Runtime。

例外包括:

  • registerTool() 在加载阶段合法,Refresh 此时是 No-op;
  • Provider Registration 会先进入 Pending Queue;
  • exec() 直接调用本地 Command Runner,不依赖 Session Binding。

13. 执行 Factory

Loader 调用:

await factory(api);

所以 Async Factory 会阻塞该 Extension 的加载。Paths 又在 for...of 中逐个 Await,Factory 执行顺序与 Extension Path 顺序一致。

Factory Import 或执行抛错时:

{
  extension: null,
  error: "Failed to load extension: ..."
}

失败项不进入 Extensions Array,后续 Path 继续加载。

14. LoadExtensionsResult

最终 Loader 返回:

{
  extensions: Extension[],
  errors: Array<{ path, error }>,
  runtime: ExtensionRuntime
}

错误是收集式,不是第一个失败就终止全部加载。桌面 Agent 应在诊断页展示 Path 与 Error, 否则 Extension 失效会表现成“注册的 Tool 不见了”。

15. Inline Factory

SDK 宿主还可以传入 Inline Extension Factory。它没有磁盘 Module Import:

Factory
→ loadExtensionFromFactory()
→ <inline:...> Extension

Inline Factory 与文件 Extension 最终进入同一个 Extensions Array 和 Shared Runtime。 它适合应用内置行为,不需要伪造临时 .ts 文件。

16. ExtensionRunner 怎样形成

AgentSession 拿到 extensionsResult 后构造:

new ExtensionRunner(
  extensions,
  runtime,
  cwd,
  sessionManager,
  modelRegistry,
);

Runner 保留有序 Extension Array,并负责:

  • 遍历 Handler;
  • 汇总 Tool、Flag 和 Command;
  • 创建运行时 Context;
  • 记录 Handler Error;
  • 管理 Shared Runtime Binding。

此时“注册结果”有了调度者,但 Core Action 还需要下一步绑定。

17. AgentSession Bindings

_bindExtensionCore() 把真实实现写入 Runtime:

sendMessage        → AgentSession Message API
appendEntry        → SessionManager
get/setActiveTools → Tool Registry
setModel           → Model Runtime
compact            → Session Compaction
abort              → 当前 Run
...

_applyExtensionBindings() 再连接宿主提供的 UI Context、Command Context Actions、Shutdown Handler 和 Error Listener。

这解释了为什么 Factory 不直接得到 AgentSession:Loader 先收集声明,宿主随后把合适的 运行能力注入 Runtime。

18. Tool 怎样进入 Agent

Runner 的 getAllRegisteredTools() 按 Extension 顺序合并,同名时第一次注册保留。

wrapRegisteredTools() 再把 ToolDefinition 变成 Agent Core 能执行的 AgentTool。最后 AgentSession 刷新 Tool Registry 和 Active Tool Names。

flowchart LR
    D["ToolDefinition"] --> G["RegisteredTool + SourceInfo"]
    G --> W["wrapRegisteredTool"]
    W --> A["AgentTool"]
    A --> R["Tool Registry"]

Tool Event 拦截不是 Wrapper 完成,而是 AgentSession 连接 Agent Core Hook 后交给 Runner。

最终 Registry 还有一层覆盖:AgentSession 先放 Built-in Tool,再写入 Extension/SDK Custom Tool,因此 Extension Tool 与 Built-in 同名时会覆盖 Built-in;SDK customTools 又排在 Extension Tool 之后。Runner 的 First Wins 只决定多个 Extension 之间的 Winner。

19. 顺序、重名与冲突

以下表格描述跨 Extension 聚合;同一 Extension 内已在第 11 节说明为 Map 后写覆盖:

注册项 冲突行为
Tool Runner 第一个 Name 胜出;Resource Loader 加 Conflict Error
Flag 第一个 Name 胜出;Resource Loader 加 Conflict Error
Command 跨 Extension 全部保留;同名改为 name:1name:2
Event Handler 全部保留,按 Extension/注册顺序运行
Shortcut 有单独的 Built-in/Extension 冲突规则

当前 detectExtensionConflicts() 只主动为跨 Extension 的 Tool 与 Flag 生成这类 Load Error, 不要笼统说“所有重名都会报同一种错误”。

20. Reload 发生什么

AgentSession.reload()

  1. 保存 Flag Values;
  2. session_shutdown,Reason 为 reload
  3. Reload Settings;
  4. Reset Provider;
  5. Resource Loader 清 Cache 并重新解析、导入、执行 Factory;
  6. 创建新的 Runner 并绑定;
  7. 恢复 Flag Values;
  8. 刷新全部 Extension Tool;
  9. 若宿主绑定存在,发新的 session_start
  10. 再执行 resources_discover 扩展资源。

Extension 自己启动的 Watcher/Process 必须在 Shutdown 中清理,否则 Reload 会叠加资源。

21. 一次完整加载时序

sequenceDiagram
    participant PM as PackageManager
    participant RL as ResourceLoader
    participant L as Extension Loader
    participant F as Factory
    participant S as AgentSession
    participant R as ExtensionRunner

    RL->>PM: resolve()
    PM-->>RL: ordered paths + metadata
    RL->>L: loadExtensionsCached(paths)
    L->>F: import + await factory(api)
    F-->>L: registrations in Extension
    L-->>RL: extensions + errors + runtime
    RL->>RL: apply SourceInfo/conflicts
    RL-->>S: LoadExtensionsResult
    S->>R: new ExtensionRunner(...)
    S->>R: bindCore/bindUI
    S->>S: wrap tools + refresh registry

22. 常见误区

“文件被发现就开始拦截”

错误。还要 Import、执行 Factory、创建 Runner、绑定 Core。

“生产路径只调用 discoverAndLoadExtensions”

错误。当前完整 coding-agent 路径由 Package Manager 与 Resource Loader 组织。

“Factory 能在加载时调用所有 Runtime Action”

错误。大多数 Action 在 Bind 前是 Throwing Stub。

“一个 Extension 加载失败会终止全部 Extension”

错误。Loader 收集错误并继续后续 Path。

“所有重名都按后者覆盖”

错误。Tool/Flag First Wins,Command 会获得带序号的 Invocation Name。

23. 本章小结

  • Extension 从 Path、Factory、Registration Container 到 Runtime 逐层形成;
  • 完整 coding-agent 路径先由 Package Manager 解析资源,再由 Loader 导入;
  • Loader Helper 与 PackageManager 生产扫描都只看一层;后者还处理 Root Entry、Ignore、 Hidden Directory、node_modules 与 Symlink;
  • CLI、项目、用户、Package 与 Inline Factory 有明确优先顺序;
  • Jiti 加载 TS/JS,默认导出必须是 Factory Function;
  • Extension Cache 保存 Factory,不保存已执行 Extension Object;
  • Factory 顺序执行,失败被收集,不阻断后续 Path;
  • SourceInfo 在 Loader 先合成,Resource Loader 后补真实来源;
  • Registration 写 Extension,Action 委托 Shared Runtime;
  • Runner 负责调度,AgentSession 负责绑定真实 Core Actions;
  • Tool/Flag/Command 的冲突语义不同;
  • Reload 会 Shutdown 旧资源、重新加载 Factory、重建 Runner 和 Tool Registry。

24. 自测

  1. discoverAndLoadExtensions() 与当前完整 Resource Loader 路径有什么区别?
  2. Extension Directory 会无限递归扫描吗?
  3. Loader Cache 保存 Factory 还是 Extension Object?
  4. 为什么多数 Action 不能在 Factory 阶段调用?
  5. 一个 Factory 抛错后,其他 Extension 是否继续加载?
  6. SourceInfo 为什么分两个阶段形成?
  7. Tool、Flag、Command 同名时各怎么处理?
  8. Reload 时为什么必须处理 session_shutdown
  9. Tool/Flag First Wins 时,CLI、项目、用户、Package 与 Inline Factory 谁优先?