Pi Extension 机制及工作原理
Pi 的核心理念是极致可扩展,最小化核心。 Plan Mode、Sub-agent、权限弹窗、待办列表,内核都不做; 用户通过 Extension API 按需构建。本文拆开 loader / runner / wrapper 三层。
一句话
Extension 机制是插件化的事件驱动架构: 内核只保留四种基础工具 + TUI,行为决策权全部在 Extension 层。 piex 的每一个 package 都只走这套标准 API。
设计哲学:为什么用 Extension?
Pi is aggressively extensible so it doesn't have to dictate your workflow.
其他 AI 编码助手内置的特性(Plan Mode、Sub-agent、MCP、权限弹窗、待办列表、后台 Shell), pi 全部不做。取而代之的是一套 Extension 机制,让用户按自己需要构建这些能力。
内核只做最小的事:四种基础工具(read、write、edit、bash)加上基本 TUI。 所有高级特性通过 Extension 实现,不侵入核心代码。
三层架构
ExtensionFactory
用户写的 export default function(pi)。注册工具、命令、事件 handler。
loader.ts — 加载 & 发现 & API 工厂
- 发现扩展路径(全局 / 项目 / 配置)
- jiti 运行时加载 TypeScript
- 执行工厂函数 → 填充 Extension 对象
- 创建共享 ExtensionRuntime
runner.ts — 编排 & 事件分发
- bindCore() 注入真实实现
- createContext() 构造 ExtensionContext
- 按加载顺序串行分发事件
- 错误隔离,单扩展不拖垮 pi
wrapper.ts — 工具包装
注册的 ToolDefinition → AgentTool,供 agent-core 调用。
一、加载层(loader.ts)
运行时依赖解析
Extension 是 TypeScript 文件,通过 jiti 在运行时直接加载,无需编译。模块别名保证扩展 import 的是 pi 内部打包实例:
| 模式 | 解析方式 | 说明 |
|---|---|---|
| Bun 二进制 | virtualModules |
将 pi 系列包直接注入 jiti 缓存 |
| Node.js / 开发 | alias |
映射到本地 workspace 路径 |
扩展发现
discoverAndLoadExtensions() 按优先级扫描三处(不递归,只一层):
Priority 1
项目级 · {cwd}/.pi/extensions/
需信任项目后才加载。
Priority 2
全局 · ~/.pi/agent/extensions/
对所有项目生效。
Priority 3
配置 · settings.json → extensions
显式路径;npm 包通过 package.json 的 pi.extensions 字段声明入口。
extensions/
├── my-ext.ts → 单文件扩展
├── my-package/ → 子目录
│ ├── package.json → 优先读 pi.extensions
│ └── index.ts
└── with-deps/ → 可带 node_modules
├── package.json
└── src/index.ts
Extension 对象模型
每个扩展加载后生成独立 Extension 对象,数据不共享:
interface Extension {
sourceInfo: SourceInfo;
handlers: Map<string, Function[]>;
tools: Map<string, RegisteredTool>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<string, ExtensionShortcut>;
messageRenderers: Map<string, Function>;
entryRenderers: Map<string, Function>;
}
ExtensionAPI 的两层委托
注册类 → Extension 自身
pi.registerTool()pi.on("event", fn)pi.registerCommand()
数据归属扩展,互不污染。
行动类 → 共享 Runtime
pi.sendMessage()pi.setActiveTools()pi.appendEntry()pi.exec()
需要与会话系统交互,走全局后端。
ExtensionRuntime:延迟绑定
共享 Runtime 初始化时,所有 action 方法都是 throwing stub。
扩展加载期是纯声明阶段,不能发消息、改工具集。
bindCore() 后 stub 被替换为真实 AgentSession 实现。
Provider 注册队列
pi.registerProvider() 在加载期不会立即执行,而是压入
pendingProviderRegistrations,在 bindCore() 时批量冲刷,
解决 ModelRegistry 尚未就绪的时序问题。
二、运行层(runner.ts)
bindCore:连接真实世界
runner.bindCore(
actions, // sendMessage, appendEntry, ...
contextActions, // getModel, isIdle, abort, compact, ...
providerActions, // registerProvider(可选)
);
- 注入 actions → 替换 throwing stub
- 保存 context 闭包(getModel、isIdle…)
- 冲刷 provider 队列
- 之后 registerProvider 立即生效
createContext:懒求值
ExtensionContext 用 getter,每次访问实时取值,
避免 session 切换后闭包快照过时:
return {
get ui() { return runner.uiContext; },
get cwd() { return runner.cwd; },
get model() { return getModel(); },
get signal() { return runner.getSignalFn(); },
isIdle: () => runner.isIdleFn(),
compact: (opts) => runner.compactFn(opts),
};
事件分发的三种模式
按扩展加载顺序串行;每个 handler 包 try-catch,异常绝不向上传播。
Fire-and-forget
session_start、turn_end、agent_start 等 30+ 事件。返回值忽略。
First-cancel-wins
session_before_* 系列。任一 handler 返回 { cancel: true } 即停止后续分发。
Chain 链式转换
tool_result、context、before_agent_start。修改累积传递,下一 handler 看到上一步结果。
async emitToolResult(event) {
const currentEvent = { ...event };
for (const ext of this.extensions) {
for (const handler of handlers) {
const result = await handler(currentEvent, ctx);
if (result?.content) currentEvent.content = result.content;
if (result?.details) currentEvent.details = result.details;
if (result?.isError) currentEvent.isError = result.isError;
}
}
return { content, details, isError };
}
hashline 的 read hook 正是挂在 tool_result 链上,
把 [PATH#TAG] 快照头注入模型可见结果。
Stale Context 保护
Session 切换 / fork / reload 后,invalidate() 标记 stale,
旧 pi / ctx 上任何操作都抛异常,
防止扩展误用旧引用(最容易出 bug 的地方)。
三、包装层(wrapper.ts)
扩展工具经 wrapRegisteredTool() 变成 agent-core 的 AgentTool。
每次 execute 创建新 context;若执行期间动态 registerTool,
返回 addedToolNames 通知系统刷新工具集。
工具覆盖
注册与内置同名工具(如 edit)即可覆盖。省略的 render 槽位自动继承内置实现。
动态注册
扩展可在 execute 期间注册新工具,下一轮 LLM 调用自动包含。
四、完整生命周期
1 · Boot
discoverAndLoadExtensions()
扫描路径 → jiti 加载 → factory(pi) → 返回 extensions[] + runtime
2 · Session
AgentSession 初始化
new ExtensionRunner → bindCore → bindCommandContext → setUIContext
3–5 · Start
project_trust → session_start → resources_discover
信任决策、会话启动、扩展可提供 skill/prompt/theme 路径
6–9 · Loop
用户输入 → Agent 循环
- 扩展命令匹配 / input transform
- before_agent_start 注入上下文
- 每 turn:context → provider hooks → LLM → tool_call / tool_result 链
- turn_end → agent_end → agent_settled
10 · Shutdown
session_shutdown → invalidate
标记旧 ctx 失效;reload / 切换 session 时重建 Runner
五、设计要点总结
| 设计 | 实现 | 目的 |
|---|---|---|
| 数据归属清晰 | 注册 → Extension;行动 → Runtime | 扩展独立,互不污染 |
| 延迟绑定 | Runtime 初建 throwing stub | 加载阶段纯声明 |
| Context 懒求值 | getter 实时取值 | 避免闭包过时 |
| 串行事件分发 | 按加载顺序 | 行为可预测 |
| 错误不传播 | try-catch + emitError | 单扩展不拖垮 pi |
| 三种分发模式 | Fire / Cancel / Chain | 按语义选策略 |
| Stale 保护 | invalidate() | 防 session 切换误用 |
| Provider 队列 | 加载期入队,bind 时冲刷 | 解决注册时序 |
对 piex 的意义
正因为这套 API 边界清晰,piex 才能 100% 通过 Extension 实现 hashline / dap / lsp / plan / review, 不 fork pi、不改内核,并随 pi 升级而升级。
源稿 Markdown: docs/notes/pi-extension-mechanism.md