Files
analysis_claude_code/learn-pi-agent/s09_extension_runtime

s09: Extension Runtime — 外部代码通过 API 接入

core 不改,能力从外面接进来。 Pi 边界:扩展 API 边界 —— core 暴露的是 API,不是内部对象。

上一节:s08 → s09 → 下一节:s10


问题

到 s08 为止,core 的能力(工具、资源)全都写在 core 代码里。每想加一种新玩法——一个新工具、一条新命令、对某类事件做个处理——都得改 core 自己。core 只会越来越重。

s09 要让外部代码接入 core,core 不用动就能长出新能力。


解决方案

core 暴露一个 ExtensionAPI,外部代码(叫一个 extension)只能通过它做三件事:

on(type, handler)        订阅事件
registerTool(tool)       注册工具
registerCommand(command) 注册命令

一个 extension 就是一个接收 API 的函数。它拿不到 core 的内部对象,只能用这三个方法。

关键设计:registerTool 复用的是 s02 就有的 Tool 类型,注册进去的工具直接进既有 ToolRegistry。也就是说,extension 注册的工具和 core 内置的工具,走的是同一条执行链(经过 s05 的 hook)——不分彼此。


工作原理

先定义事件和命令。 事件是 core 往外发的信号;命令是外部注册的无参动作。

export type RuntimeEvent =
  | { type: "message"; content: string }
  | { type: "done" };

export type Command = { name: string; run: () => string };

定义 API 表面。 这就是 extension 能碰的全部。

export type ExtensionAPI = {
  on(type: RuntimeEvent["type"], handler: (event: RuntimeEvent) => void): void;
  registerTool(tool: Tool): void;
  registerCommand(command: Command): void;
};

export type Extension = (api: ExtensionAPI) => void;

ExtensionRuntime 接住注册。 它构造时接收既有 registry;registerTool 直接往这个 registry 里加。

export class ExtensionRuntime {
  constructor(private registry: ToolRegistry) {}
  createApi(): ExtensionAPI {
    return {
      on: (type, handler) => { this.handlers.push({ type, handler }); },
      registerTool: (tool) => { this.registry.register(tool); }, // 注入既有 registry
      registerCommand: (command) => { this.commands.set(command.name, command); },
    };
  }
  emit(event) { /* 按类型匹配 handler,不是全调 */ }
  runCommand(name) { /* 找不到返回 unknown command */ }
}

两个细节:emit 按事件类型匹配 handler(不是把所有 handler 都调一遍);命令找不到时返回一句说明,不抛错。

这一节真正建立的是扩展 API 边界:core 对外只给一个受控的 API,extension 加的工具和内置工具同源同链,事件按类型分发。后面 s11 的权限检查会同样作用在 extension 注册的工具上,因为它们本就在同一个 registry 里。


试一下

运行:

npm run s09

输出类似:

s09: Extension Runtime

[registry]
tool: current_time
tool: note

[event] message: hello from core

[command]
/status -> extension is active

[tool via extension]
note -> note saved: hi

观察重点:[registry] 里 current_time 是内置的、note 是 extension 注册的,两者同处一个 registry;[tool via extension] 里 extension 的工具走的还是 executeToolCall 那条既有执行链。


接入主线

s09 在 s08 上累积。相对 s08 的变更:

组件 s08 s09
新增类型 — RuntimeEvent(U2 全局唯一)/ Command / Extension / ExtensionAPI
新增类 — ExtensionRuntime(构造接收既有 ToolRegistry)
工具来源 只有 core 内置 core 内置 + extension 注册(同一 registry)
ProviderInput / 主循环 — 不变(纯新增,无 U1 升级)

焊接点:ExtensionRuntime 构造接收既有 ToolRegistry;registerTool 往里加。extension 工具和内置工具同源,执行时都走 executeToolCall。


接下来

现在 core 能产生结果,但结果怎么展示(打印?JSON?)写死在代码里。

下一节会把"产生结果"和"展示结果"分开:同一个 core,接不同的输出方式。

进入下一节:s10。


Pi 源码溯源:Extension API 和它的 20 多个事件

教学版的 ExtensionAPI 暴露 on/registerTool/registerCommand 三个方法。Pi 的 packages/coding-agent 有一套庞大得多的 extension 系统。

源码在哪

  • packages/coding-agent/src/core/extensions/types.ts — ExtensionAPI 类型
  • packages/coding-agent/src/core/extensions/loader.ts — 发现 + 加载
  • packages/coding-agent/src/core/extensions/runner.ts — 运行时
  • .pi/extensions/ — 项目级扩展目录

API 比教学版大得多

教学版三个方法。Pi 的 ExtensionAPI(types.ts)有一长串:

interface ExtensionAPI {
  // 注册能力
  registerTool(tool): void;
  registerCommand(name, options): void;
  registerFlag(name, { description, type, default }): void;
  // 订阅事件(20+ 种)
  on(event: "session_start" | "tool_execution_start" | "before_agent_start" | ..., handler): void;
  // 运行时动作
  sendMessage(msg): void;
  setModel(model): void;
  getActiveTools(): AgentTool[];
  registerProvider(...) / unregisterProvider(...): void;
  exec(command): Promise<...>;
}

教学版的 on/registerTool/registerCommand 是它的一个子集。Pi 的 extension 不仅能加工具/命令,还能改模型、注册 provider、执行命令、订阅 20 多种生命周期事件。

20 多种事件

教学版只有 message / done 两种 RuntimeEvent。Pi 的 extension 能订阅 session_start、tool_execution_start、before_agent_start、project_trust(s11 用它决定信任)……覆盖整个 agent 生命周期。每个事件的 handler 还能返回结果反向影响 core(比如 before_agent_start 的返回值能改本轮配置)。

四种发现来源

discoverAndLoadExtensions(loader.ts:557)从四个地方找扩展:

1. cwd/.pi/extensions/                项目级
2. agentDir/.pi/extensions/           全局级
3. package.json 的 pi.extensions 字段  包声明
4. 命令行传入的路径                    CLI 级

教学版的 extension 是手动 runtime.use(...)。Pi 是自动发现——放对目录就加载。

冲突检测 + 沙箱

两个扩展注册同名工具怎么办?detectExtensionConflicts(loader.ts:988)检查工具/命令/标志名冲突,通过 ResourceDiagnostic 报告,保留先加载的。扩展代码跑在 jiti 沙箱里,每个扩展有 sourceInfo 标记来源和权限级别——这是教学版完全没有的隔离层。

notInitialized 守卫

createExtensionRuntime(runner.ts)有个巧思:扩展加载阶段(执行 factory 函数时),runtime 的动作方法(sendMessage 等)都指向 notInitialized——一调用就抛错。因为加载时 core 还没就绪,扩展只能"注册",不能"动作"。加载完成后才换上真实实现。

一句话

教学版的 ExtensionAPI 立的是"外部代码通过受控 API 接入 core"。Pi 把它坐实成 20 多个事件 + 注册 tool/command/flag/provider + 四种自动发现 + 冲突检测 + 沙箱隔离。教学版只保留最小接入(on/registerTool/registerCommand + 手动 use),但"core 暴露 API 而非内部"这条边界一致。