Files
analysis_claude_code/learn-pi-agent/s09_extension_runtime/README.md
2026-06-16 00:10:35 +08:00

201 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# s09: Extension Runtime — 外部代码通过 API 接入
> *core 不改,能力从外面接进来。*
> **Pi 边界**:扩展 API 边界 —— core 暴露的是 API不是内部对象。
[上一节s08](../s08_context_resources/) → `s09` → [下一节s10](../s10_runtime_modes/)
---
## 问题
到 s08 为止core 的能力(工具、资源)全都写在 core 代码里。每想加一种新玩法——一个新工具、一条新命令、对某类事件做个处理——都得改 core 自己。core 只会越来越重。
s09 要让**外部代码**接入 corecore 不用动就能长出新能力。
---
## 解决方案
core 暴露一个 `ExtensionAPI`,外部代码(叫一个 extension只能通过它做三件事
```text
on(type, handler) 订阅事件
registerTool(tool) 注册工具
registerCommand(command) 注册命令
```
一个 extension 就是一个接收 API 的函数。它拿不到 core 的内部对象,只能用这三个方法。
关键设计:`registerTool` 复用的是 s02 就有的 `Tool` 类型,注册进去的工具直接进**既有 ToolRegistry**。也就是说extension 注册的工具和 core 内置的工具,走的是**同一条执行链**(经过 s05 的 hook——不分彼此。
---
## 工作原理
**先定义事件和命令。** 事件是 core 往外发的信号;命令是外部注册的无参动作。
```ts
export type RuntimeEvent =
| { type: "message"; content: string }
| { type: "done" };
export type Command = { name: string; run: () => string };
```
**定义 API 表面。** 这就是 extension 能碰的全部。
```ts
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 里加。
```ts
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 里。
---
## 试一下
运行:
```sh
npm run s09
```
输出类似:
```text
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](../s10_runtime_modes/)。
---
<details>
<summary>Pi 源码溯源Extension API 和它的 20 多个事件</summary>
教学版的 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`)有一长串:
```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`)从四个地方找扩展:
```text
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 而非内部"这条边界一致。
</details>