边界:s10 讲了什么,本篇讲什么
| 能力面 | s10 已讲 | 本篇覆盖 |
|---|---|---|
| 事件订阅 | 三类钩子 + 6 个事件 | 剩余 20 个事件清单 |
| 工具注册 | — | ToolDefinition 完整契约 + 与 dispatch table 桥接 |
| 命令注册 | — | registerCommand + 会话控制权 |
| Provider 注册 | — | ProviderConfig + OAuth + 延迟 flush |
| UI 扩展 | — | ExtensionUIContext 十余项能力 |
| 快捷键 / CLI flag | — | registerShortcut + registerFlag + 冲突仲裁 |
| 自定义压缩 | — | session_before_compact 事件模式 |
| 模式扩展 | — | subagent vs plan-mode 两种思路 |
| 加载生命周期 | throwing stubs + await factory | 完整 7 阶段 |
Extension 自身加载生命周期 7 阶段
s10 讲的是扩展挂载 handler 后监听 agent 运行时事件。本节问的是 extension 实例本身的加载/注册/销毁——loader 和 runner 管理 extension 的视角。
- Discover:扫描
cwd/.pi/extensions/(项目级)+agentDir/extensions/(全局级)。三种入口:直接 .ts 文件 / 子目录 index.ts / 子目录 package.json 含pi.extensions字段。 - Load module:jiti 运行时 import TS(
moduleCache: false),取module.default作为 factory。 - Init factory:
await factory(api)执行所有api.on/api.registerTool注册。此阶段 action 方法是 throwing stubs——防止扩展在加载期误调sendMessage。registerProvider 入队pendingProviderRegistrations。 - Bind core:throwing stubs 替换成真实 actions(sendMessage / setModel / setActiveTools…)。flush pendingProviderRegistrations 到 ModelRegistry。从此刻起 registerProvider 立即生效,无需 /reload。
- Bind command context:注入 waitForIdle / newSession / fork / navigateTree / switchSession / reload。只有 slash 命令 handler 能拿到这些会话控制权,事件 handler 拿不到——权限分层。
- Bind UI:noOpUIContext 替换为真实 TUI 实现。print/RPC 模式保留 noOp。
- Shutdown:
invalidate()标记 stale + emitsession_shutdown。reason 含quit/reload/new/resume/fork,扩展可据 reason 决定清理策略。
两层生命周期的交汇点:session_shutdown 既是 agent 运行时事件(s10 视角),也是 extension 实例销毁信号(本节视角)。扩展监听它做 cleanup,是两层唯一直接交互的地方。
Custom Tool 完整契约
s02 的 dispatch table 是 AgentTool[]。扩展注册的工具经 wrapRegisteredTool 转成 AgentTool 喂给 dispatch table——与内置工具地位平等,因此可以同名注册覆盖内置工具。
pi.registerTool(defineTool({
name: "hello",
label: "Hello",
description: "Say hello to someone",
promptSnippet: "Greet a person by name.",
promptGuidelines: ["Always be polite"],
parameters: Type.Object({ name: Type.String() }),
executionMode: "sequential",
renderShell: "self",
execute: async (id, params, signal, onUpdate, ctx) => {
return { content: [{ type: "text", text: "Hello, " + params.name + "!" }] };
},
renderCall: (args, theme) => CustomCallComponent,
renderResult: (result, opts, theme) => CustomResultComponent,
})); defineTool 的作用:直接传对象字面量给 registerTool 会丢失泛型推断,defineTool 包装后 TParams 完整保留。execute 返回 AgentToolResult,与内置工具同形。
执行模式:sequential(串行,默认)/ parallel(可与其他并行工具并发)。renderShell: "self" 时工具自渲染外壳,"default" 走默认彩色框。
源码锚点:types.ts:426-488 ToolDefinition · wrapper.ts:17-19 wrapRegisteredTool 桥接 · hello.ts 最小示例 · subagent/index.ts 完整示例(renderCall/renderResult/并发控制/子进程)
Custom Command + 会话控制权
pi.registerCommand("tools", {
description: "Toggle tools on/off",
getArgumentCompletions: (prefix) => filteredItems,
handler: async (args, ctx) => {
const choice = await ctx.ui.select("Select tool", items);
if (choice) { pi.setActiveTools([choice]); }
},
});
handler 收到原始 args: string,扩展自行解析——pi 不做参数 schema 校验,KISS。
重名冲突仲裁:两个扩展都注册 /foo,invocationName 自动变成 /foo:1 和 /foo:2。避免静默覆盖。
ExtensionCommandContext 的会话控制权:只有 slash 命令 handler 拿到 waitForIdle / newSession / fork / navigateTree / switchSession / reload。事件 handler 拿到的是普通 ExtensionContext,不能切会话。这是权限分层——用户主动触发的命令才允许做高风险操作。
源码锚点:types.ts:333-364 ExtensionCommandContext · runner.ts:512-546 resolveRegisteredCommands · commands.ts + tools.ts
Custom Provider + 延迟 flush
pi.registerProvider("custom-anthropic", {
baseUrl: "https://api.anthropic.com",
apiKey: process.env.ANTHROPIC_API_KEY,
api: anthropicMessagesApi(),
models: [{ id: "claude-sonnet-4-5", ... }],
oauth: { login, refreshToken, getApiKey },
streamSimple: (model, ctx, opts) => customSSEStream,
headers: { "x-custom": "..." },
authHeader: true,
}); 与 s08 的关系:s08 讲 pi-ai 包的 LlmProvider 抽象。扩展的 registerProvider 最终调用 ModelRegistry.registerProvider。两条路径:提供 streamSimple 完全自定义 SSE 流;不提供则用 pi-ai 内置流处理器,只覆盖 baseUrl/headers。
延迟 flush 机制:加载阶段 ModelRegistry 还没绑定,registerProvider 入队 pendingProviderRegistrations。bindCore 后 flush——单个失败走 emitError 不中断。此后 registerProvider 立即生效,无需 /reload。
源码锚点:types.ts:1240-1348 ProviderConfig · runner.ts:301-335 bindCore flush · custom-provider-anthropic 完整示例(OAuth + streamSimple + 工具名映射)
UI 扩展能力面
UI 能力不在 ExtensionAPI 上,而在 ExtensionContext.ui(ExtensionUIContext)。设计原因:UI 依赖 TUI 实例,print/RPC 模式没有 TUI(noOpUIContext 全空实现),所以 UI 能力必须在 context 上。
| 类别 | API | 示例 |
|---|---|---|
| 对话框 | select / confirm / input / editor | permission-gate.ts |
| 通知 | notify(message, type?) | pirate.ts |
| 状态栏 | setStatus(key, text) | plan-mode.ts |
| Widget | setWidget(key, content, placement) | widget-placement.ts · snake.ts |
| Footer/Header | setFooter(factory) / setHeader(factory) | custom-footer.ts |
| Overlay 组件 | custom(factory, overlayOptions) | doom-overlay · snake.ts |
| 编辑器替换 | setEditorComponent(factory) | modal-editor.ts · rainbow-editor.ts |
| 自动补全 | addAutocompleteProvider(factory) | github-issue-autocomplete.ts |
| 主题 | theme / getTheme / setTheme | mac-system-theme.ts |
| 终端标题 | setTitle(title) | titlebar-spinner.ts |
| 原始输入 | onTerminalInput(handler) | 自定义按键监听 |
| 消息渲染 | registerMessageRenderer(customType, renderer) | message-renderer.ts |
CustomEditor 模式:扩展继承 CustomEditor 基类重写 handleInput,对未处理的键调 super.handleInput(data) 让 app keybinding 继续生效——Vim 风格 modal editor 的标准模式。
doom-overlay 的意义:35 FPS 实时渲染 DOOM,证明 overlay 能承载真实游戏,不是玩具。任何"能不能在 pi 里跑 X"的疑问都有答案了。
Keybinding + CLI Flag
pi.registerShortcut(Key.ctrlAlt("p"), {
description: "Toggle plan mode",
handler: (ctx) => togglePlanMode(),
});
pi.registerFlag("plan", {
type: "boolean",
default: false,
});
const enabled = pi.getFlag("plan"); 快捷键冲突仲裁(三类):
- 扩展键撞保留键(17 个,如 app.interrupt / app.exit)→ warning + skip(扩展无法覆盖)
- 扩展键撞非保留内置键 → warning 但扩展胜出
- 多个扩展注册同一键 → warning,后注册的胜出
Flag 生命周期:registerFlag 时若有 default 且 flagValues 没有该 key,写入默认值。CLI 解析后 setFlagValue 覆盖。扩展通过 pi.getFlag(name) 读取——先校验扩展自己注册了该 flag,再从 runtime 读取。
源码锚点:runner.ts:62-80 保留键清单 · runner.ts:417-460 冲突检测 · plan-mode + preset
Custom Compaction:事件驱动可替换行为
pi 没有单独的 registerCompaction API。自定义压缩通过 session_before_compact 事件 + result 字段实现——这是"事件驱动可替换行为"的典型模式。所有"可替换核心行为"都用 before_xxx 事件,而不是新增 registerXxx API。
pi.on("session_before_compact", (event, ctx) => {
const model = ctx.modelRegistry.find("google", "gemini-2.5-flash");
const text = serializeConversation(event.preparation.messagesToSummarize);
const summary = await complete(model, { messages: [{ role: "user", content: text }] }, { signal: event.signal });
return {
compaction: {
summary,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
};
});
返回 { compaction } 替换默认压缩结果;返回 { cancel: true } 取消压缩;返回 undefined 回退默认。失败时 return 即可,不用抛错。
源码锚点:types.ts:536-549 事件签名 · custom-compaction.ts 完整示例 · trigger-compact.ts 主动触发
模式扩展两种思路
extension 没有直接的 registerMode API。两种实现思路:
| 维度 | subagent(spawn 子进程) | plan-mode(白名单+注入) |
|---|---|---|
| context 隔离 | 物理隔离(子进程) | 逻辑隔离(同进程,setActiveTools 限制) |
| 实现复杂度 | 高(子进程管理、JSON 协议、并发控制) | 中(事件组合) |
| 可观测性 | 子进程输出需自定义渲染 | 主循环输出,原生可观测 |
| 适合场景 | 长任务委派、多 agent 协作 | 模式切换、安全约束 |
| 示例 | subagent/index.ts | plan-mode/index.ts |
plan-mode 的能力组合(几乎用到了一半的扩展能力):registerFlag + registerCommand + registerShortcut + setActiveTools + before_agent_start 注入 system prompt + tool_call 拦截 + context 过滤 + turn_end 提取标记 + session_start 恢复状态 + setStatus + setWidget + appendEntry 持久化。
官方示例索引
| 示例 | 演示能力 | 教学价值 |
|---|---|---|
| hello.ts | registerTool + defineTool | 最小可运行扩展 |
| tools.ts | registerCommand + setActiveTools + 持久化 | 有状态扩展范式 |
| commands.ts | registerCommand + autocomplete + getCommands | 命令系统元命令 |
| custom-provider-anthropic | registerProvider + OAuth + streamSimple | provider 最完整示例 |
| doom-overlay | custom + overlay | UI 极限能力(35 FPS) |
| snake.ts | custom + setInterval + 持久化 | UI 游戏教学起点 |
| plan-mode | flag + command + shortcut + 事件组合 | 模式扩展综合应用 |
| subagent | registerTool + spawn + 并发控制 | 最完整工具示例 |
| custom-compaction.ts | session_before_compact | 事件驱动可替换行为 |
| modal-editor.ts | setEditorComponent + CustomEditor | Vim 风格编辑器 |
| tool-override.ts | 同名 registerTool 覆盖内置 | 工具覆盖模式 |
| event-bus.ts | pi.events EventBus | 扩展间通信 |
其余事件清单
s10 讲了 6 个事件。剩余 20 个按类别分组:
| 类别 | 事件 | 用途 |
|---|---|---|
| 会话树 | session_tree / session_before_tree / session_before_switch / session_before_fork | 与 s14 呼应,分支切换事件 |
| Provider | before_provider_request / after_provider_response | 请求/响应拦截 |
| 输入 | input / user_bash | 用户输入变换 / ! 命令拦截 |
| 工具细粒度 | BashToolCall / ReadToolCall / EditToolCall / WriteToolCall / GrepToolCall / FindToolCall / LsToolCall / CustomToolCall | 按工具类型的事件 + isToolCallEventType 类型守卫 |
| 工具结果 | tool_result | 工具结果变换 |
| 工具执行 | tool_execution_start / tool_execution_update / tool_execution_end | 执行进度事件 |
| 模型 | model_select / thinking_level_select | 模型/thinking 切换 |
| 消息流 | message_start / message_update | 消息流式事件(s10 只讲 message_end) |
| Turn/Agent | turn_start / turn_end / agent_start / agent_end | turn/agent 级事件 |
| 资源 | resources_discover | 动态注入 skills/prompts/themes 路径 |
Action 方法集
扩展不止被动监听,还能主动操作 agent。bindCore 后这些方法从 throwing stubs 变成真实实现:
- 消息:sendMessage / sendUserMessage / appendEntry
- 工具:setActiveTools(白名单)/ getActiveTools / getAllTools
- 模型:setModel / setThinkingLevel / getThinkingLevel
- 会话:setSessionName / setLabel(给 entry 打标签)
- 执行:exec(执行 shell 命令)
- 压缩:compact(主动触发压缩)
- 查询:getContextUsage(上下文 token 用量)
能力全景
ExtensionAPI
├─ on(event, handler) 26 个事件(s10 讲了 6 个,本篇补 20 个)
├─ registerTool(tool) 注入 dispatch table(s02)
├─ registerCommand(name, opt) slash 命令 + 会话控制权
├─ registerProvider(name, cfg) 注入 ModelRegistry(s08)+ OAuth + streamSimple
├─ registerShortcut(key, opt) 快捷键 + 冲突仲裁
├─ registerFlag(name, opt) CLI flag + getFlag
├─ registerMessageRenderer 自定义消息渲染
├─ pi.events EventBus 扩展间通信
└─ Action 方法 sendMessage / setModel / setActiveTools / exec...
ExtensionContext.ui(TUI 模式独有)
├─ select / confirm / input 对话框
├─ notify / setStatus 通知 + 状态栏
├─ setWidget / setFooter Widget / Footer / Header
├─ custom(factory, { overlay }) Overlay 自定义组件(doom-overlay)
├─ setEditorComponent 替换编辑器(modal-editor)
├─ addAutocompleteProvider 自动补全
├─ theme / setTheme 主题
└─ setTitle 终端标题