Files
analysis_claude_code/learn-pi-agent/s09_extension_runtime

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

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

上一节s08s09下一节s10


问题

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

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


解决方案

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 接住注册。 它构造时接收既有 registryregisterTool 直接往这个 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 对外只给一个受控的 APIextension 加的工具和内置工具同源同链,事件按类型分发。后面 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
新增类型 RuntimeEventU2 全局唯一)/ Command / Extension / ExtensionAPI
新增类 ExtensionRuntime(构造接收既有 ToolRegistry
工具来源 只有 core 内置 core 内置 + extension 注册(同一 registry
ProviderInput / 主循环 不变(纯新增,无 U1 升级)

焊接点ExtensionRuntime 构造接收既有 ToolRegistryregisterTool 往里加。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.tsExtensionAPI 类型
  • packages/coding-agent/src/core/extensions/loader.ts — 发现 + 加载
  • packages/coding-agent/src/core/extensions/runner.ts — 运行时
  • .pi/extensions/ — 项目级扩展目录

API 比教学版大得多

教学版三个方法。Pi 的 ExtensionAPItypes.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_starttool_execution_startbefore_agent_startproject_trusts11 用它决定信任)……覆盖整个 agent 生命周期。每个事件的 handler 还能返回结果反向影响 core比如 before_agent_start 的返回值能改本轮配置)。

四种发现来源

discoverAndLoadExtensionsloader.ts:557)从四个地方找扩展:

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

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

冲突检测 + 沙箱

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

notInitialized 守卫

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

一句话

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