08-扩展开发与事件系统
08 扩展开发与事件系统
读这一篇的价值:扩展是 Pi 唯一能改变可执行行为的机制(工具、命令、事件、上下文、供应商、UI);把事件契约弄明白,你就同时拿到了“代理循环”的第二层控制权。
1. 定位与信任前提
扩展是加载进 Pi 进程的 TypeScript 模块,与 Pi 同 OS 权限。它能看 prompt、工具调用、文件、凭据与会话历史——所以只加载你信任的来源。
什么时候该写扩展(而不是 AGENTS.md / 技能 / 提示模板):需要新增可执行的集成点(工具、命令、事件处理、模型供应商、会话状态、终端 UI)时。
2. 最小扩展与加载
// ~/.pi/agent/extensions/hello.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.registerCommand("hello", {
description: "Show a greeting",
handler: async (name, ctx) => {
ctx.ui.notify(`Hello, ${name || "world"}!`, "info");
},
});
}
- 开发期直接加载:
pi --extension ./hello.ts;Pi 用jiti,本地 TS 扩展无需预编译。 - 目录形式:
index.ts/index.js作为入口;npm 依赖放在相邻package.json。 ctx.reload()会替换整个扩展运行时:reload 之后的代码不得复用旧运行时的状态。- 只有个人扩展与命令行扩展能参与
project_trust事件(它在项目扩展加载之前跑)。
3. 运行时生命周期
input → before_agent_start → …模型/消息/工具事件… → agent_end
↘ 重试、恢复、压缩、排队工作可能继续
↘ agent_before_settle(可追加条目并请求一次继续)
↘ agent_settled(终态,只能通知)
硬约束:
- factory 可以同步或异步;异步时 Pi 会等它完成再继续启动(可用于拉配置、注册启动期供应商)。
- 不要在 factory 里起进程/套接字/监听器/定时器——有些调用会加载扩展但不开会话。长生命周期资源放到
session_start或真正需要它的命令/工具里建。 - 会话级资源在
session_shutdown里释放,且必须幂等(取消、reload、会话替换、进程退出会走到同一条清理路径)。 ctx.shutdown()请求有序退出进程。
4. 能力与 API 对照
| 想要的能力 | 主 API |
|---|---|
| 观察/修改生命周期行为 | pi.on() |
| 新增模型可调用的操作 | pi.registerTool() |
新增 / 命令 | pi.registerCommand() |
| 新增快捷键或 CLI 参数 | pi.registerShortcut() / pi.registerFlag() |
| 发用户消息或自定义消息 | pi.sendUserMessage() / pi.sendMessage() |
| 持久化不进上下文的会话数据 | pi.appendEntry() |
| 改活动工具、模型、思考级别 | pi 上的会话控制方法 |
| 新增模型供应商 | pi.registerProvider() |
| 新增 MCP 服务器 | pi.registerMcpServer() |
| 把每个请求路由到模型 | pi.registerVirtualModel() |
| 新增终端渲染 | Renderer 注册与 ctx.ui |
| 扩展间通信 | pi.events |
类型定义看 packages/coding-agent/src/core/extensions/types.ts。
5. 事件系统要点
处理器按扩展加载与注册顺序执行;pi.on() 返回退订函数,且变更不影响已在派发中的那次事件。事件分为三类:只通知、变换数据、替换结果或取消操作——每个事件按它声明的返回类型使用,不要假设任何返回值都生效。
几个关键事件:
| 事件 | 语义 |
|---|---|
before_agent_start | 同时给出当前 prompt 与结构化的 systemPromptOptions。优先改提示词分段/工具集/引导,让 Pi 追加 transcript delta;返回 systemPrompt 或置 forceSystemPrompt 会整块替换该次运行的系统提示词(transcript 仍记录结构化分段) |
message_end | 可替换已定稿的消息,但必须保留其 role |
tool_call | 可改输入或阻断执行(返回 {block:true, reason}) |
tool_result | 多个处理器叠加,后者看到前者的修改 |
provider_stream_event | 在每个已解析的供应商流事件被 Pi 归一化之前触发;event.data 是 Pi 能看到的最早结构化值(不一定是原始 HTTP/SSE 字节),只读;只通知不持久化;处理器按流顺序 await,慢处理器会拖延流消费 |
context / context_with_system | context 变换对话消息(不含 prompt 与工具系统消息),事后 Pi 恢复现场;context_with_system 仅当请求级变换需要接管完整 transcript 时用,并保持 index 0 为 system 消息 |
turn_end / agent_before_settle | 可行动边界:可链入 custom / custom_message / context_edit / compaction 条目并返回 continue: true 请求一次下一个模型请求。续跑条件必须加守卫,无条件续跑会死循环 |
agent_before_settle / agent_settled | 前者是最后一个可行动边界;后者是终态、只通知 |
cache_warming_decision | 用 { action: "warm" } 或 { action: "stop" } 覆盖空闲时的提示词缓存刷新;最后一个返回 action 的处理器胜出 |
user_bash | 返回 undefined 则交给下一个处理器,最后落到本地执行;返回 operations 或 result 停止传播;处理器报错会阻断命令,不会 fallback 到本地执行 |
并发注意:
- 同一条助手消息里的工具调用可能并行执行;处理某个工具事件时不得假设兄弟调用/结果已存在。
- 属于活动 turn 的嵌套工作用
ctx.signal;命令与空闲会话事件通常没有操作信号。
6. 自定义工具
一个工具包含:name、面向模型的 description、TypeBox 参数 schema、execute()。结果必须带面向模型的 content 与用于渲染/状态重建的 details(无结构化细节时显式 undefined)。
- 想让工具变成失败结果就 throw;返回对象不会被当成错误。
terminate: true仅当同一批次里每个调用的工具都同意终止时,代理才跳过自动后续请求。- 共享内存可变状态的工具要声明顺序执行。
- 改文件的工具要用
withFileMutationQueue()包住整个 read-modify-write。 - 大结果要截断,并告诉模型去哪里读完整输出。
- 结果是数据时声明
outputSchema并返回匹配的structuredContent:模型仍收到content,而程序调用方(如 codemode 脚本)收到structuredContent。没有outputSchema的工具以文本内容传给脚本。 - 要报“带数据的失败”就用
{ isError: true }返回而不是 throw:模型看到错误,脚本仍能拿到structuredContent。
嵌套调用 ctx.executeTool()
await ctx.executeTool(name, args, { signal, onUpdate });
嵌套调用会走参数校验与 tool_call / tool_result 处理器(和模型发起的调用一样),并发出 tool_execution_start/update/end,都带 parentToolCallId,其 toolCallId 由 Pi 分配为 <parent id>/<n>。
它们不产生 transcript 条目(结果只回给调用的工具,由它自己通过 onUpdate/details 上报);会话只在调用方的结果消息上保留有界记录 nestedCalls(name/args/status/duration/error,不存结果),用于压缩文件列表与 HTML 导出。超 8 KiB 的单次参数或超 32 KiB 的单次结果被省略,最多留 256 条,丢了东西的标记 complete: false。
usage 汇总规则很关键:嵌套结果的 usage(任意深度)都加到调用方工具的 usage 上,所以一个工具只报自己的 usage,不报它调用的那些工具的。ctx.tools 列出可被 ctx.executeTool() 调用的工具。
工具暴露模式 exposure
| 取值 | 含义 |
|---|---|
direct(默认) | 活动时向模型声明,且可被调用 |
model-only | 活动时向模型声明,永不可被调用(适合编排其他工具或询问用户的工具) |
codemode | 注册即可调用,并被 codemode 工具列出;不显式激活则不向模型声明 |
deferred | 同 codemode,但 codemode 不列出它,要 tool_search 找并激活 |
hidden | 已注册但不可达;重新注册为 hidden 等于“撤回”(工具无法注销) |
namespace: { name, description, instructions }分组;codemode 把同一 namespace 列在一个标题下,instructions不在列表里,脚本用describeNamespace(name)读。- 注册
direct/model-only会立即激活,其他暴露模式注册时不激活。活动集 = 向模型声明的集合,用pi.getActiveTools()/pi.setActiveTools()控制;pi.getAllTools()报告exposure、namespace、annotations。 annotations语义同 MCP:readOnlyHint/destructiveHint/idempotentHint/openWorldHint。缺失时的 MCP 默认值是不只读、可能破坏性、可能开放世界。它不被验证,但权限扩展可以用它决定哪些调用要确认(官方给出了一段tool_call+ctx.ui.confirm的确认示例)。- 编排型工具可用
prepareLoadout(loadout)在活动期间调整模型看见的内容:接收已声明工具、可调用工具与全部注册工具(带 exposure/namespace),返回替换后的descriptions与hiddenDeclarations。codemode就只用这个 hook +exposure+ctx.executeTool(),所以别的工具可以自己复刻同一套行为。
动态激活与转录影响
先注册全部工具、保持可选工具未激活,再由一个 loader 工具调 pi.setActiveTools() 选出想要的。名字必须已注册,未知名字忽略。Pi 把初始提示词与工具集记在 transcript 的第一条 system 消息里,之后在下一个请求前追加变更;无法表达这种转换的供应商会收到完整 transcript checkpoint,可能使缓存前缀失效。
7. 会话中注册 MCP 服务器
pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
pi.unregisterMcpServer("jira");
- 配置形状同
mcp.json的mcpServers条目;额外支持exposure/toolExposure/description/enabled/timeout。 - 加载期注册的服务器与会话启动一起连接;之后再注册的立即连接;注销会断开连接并使其工具不可达。
- 注册不持久化:每次加载重新注册(例如根据扩展自己的设置)。
mcp.json里同名服务器优先,/mcp会显示覆盖关系。同名重复注册会替换该扩展早先的注册;别的扩展已注册、名字非法或配置非法会抛错。
8. 上下文、状态与模式
ExtensionContext提供工作目录、mode、UI、session manager、model runtime、abort signal、上下文用量,以及压缩与 shutdown 控制。嵌套模型调用用ctx.modelRegistry.streamSimple()(供应商中立)。- 命令处理器收到
ExtensionCommandContext,额外有等待空闲、reload、树导航、会话替换等操作。这些只能在命令里调——从生命周期处理器里调可能死锁运行时。 - 会话替换会使旧 ctx 失效:切换前只捕获纯数据,会话相关的工作用
withSession给的新 ctx。
状态存放位置的选择:
| 状态类型 | 存放位置 |
|---|---|
| 跟随活动分支的工具状态 | 工具结果的 details |
| 不进模型上下文的持久数据 | pi.appendEntry() |
| 自定义内容且要发给模型 | pi.sendMessage() |
| 跨会话的数据 | 外部存储 |
分支相关状态要在 session_start 时从 ctx.sessionManager.getBranch() 重建;不要从文件里每个条目重建,因为被丢弃的分支代表另一些历史。自定义存储内容要出现在 transcript 里时,得注册条目/消息 renderer。
模式行为:扩展在 interactive / RPC / JSON / print 四种模式下都会加载。interactive 有完整终端 UI;RPC 能把支持的对话框与通知转发出去(RPC Extension UI 协议)但不能转自定义终端组件;JSON 与 print 无 UI。用 ctx.mode === "tui" 守卫纯终端行为,用 ctx.hasUI 描述 interactive 与 RPC 支持的交互。工具与事件行为要和渲染解耦,否则非交互模式会失效。
出错时 Pi 报告并尽量继续:tool_call 处理器失败会阻断该工具(fail-safe),工具执行失败变成给模型的错误结果。
9. 终端 UI(@earendil-works/pi-tui)
先用 ctx.ui 的方法,只有当 UI 需要自己的渲染、键鼠输入、焦点、布局或生命周期时才写自定义组件。
| 需求 | 用什么 |
|---|---|
| 选择/确认/输入/多行编辑器 | ctx.ui.select() / confirm() / input() / editor() |
| 非阻塞反馈 | ctx.ui.notify() / setStatus() |
| 编辑器附近的常驻内容 | ctx.ui.setWidget() |
| 替换 header / footer / editor | 对应的 ctx.ui 组件工厂 |
| 临时交互屏或 overlay | ctx.ui.custom() |
| 工具或会话条目的自定义渲染 | 扩展 renderer |
组件模型要点:
- 组件按“可用宽度”渲染行数组,可处理键鼠输入;状态或主题相关内容变化时必须
invalidate()缓存并调注入的tui.requestRender()(TUI 会合并渲染请求)。 - 每一行都必须放进给定宽度;要按可见终端列宽而非字符串长度测量(ANSI 转义、宽字符、emoji、组合字符都会改变显示宽度)。用
visibleWidth()/truncateToWidth()/sliceByColumn()/wrapTextWithAnsi(),不要自己实现宽度处理。Pi 每行结束会重置样式与超链接,所以每行要重新上样式。 - 内置组件优先复用:
Text/Markdown/Image/TruncatedText;Container/VStack/HStack/Box/Spacer;Input/Editor;SelectList/SettingsList;ScrollView;Loader/CancellableLoader;MouseRegion。 - 键盘用
matchesKey()与Key;可配置动作走注入的KeybindingsManager。要显示文本光标的组件实现Focusable并紧贴视觉光标放CURSOR_MARKER(TUI 用它摆放硬件光标以支持输入法)。包裹Input/Editor的容器必须把focused往下传,否则中日韩等 IME 候选窗会出现在错误位置。替换主编辑器请继承 Pi 的CustomEditor(保留应用快捷键与代理控制),不拥有的键要转发给基类。 - 鼠标:全屏模式把归一化鼠标事件路由给组件;未处理的滚轮滚动最近的
ScrollView,未处理的主键拖拽留给 transcript 选择,OSC 8 链接优先于外围点击区。普通模式鼠标归终端(终端自己拥有 scrollback),所以每个交互都要有键盘路径。 - overlay:
ctx.ui.custom({ overlay: true })画在现有内容之上,可控制尺寸/锚点/偏移/边距/响应式可见性;overlay handle 可改焦点或用setHidden()临时隐藏。聚焦的 overlay 会跨普通渲染保持输入所有权;要让别的组件收输入得显式释放/重定向焦点。交互结束时调工厂给的完成回调(它会 resolvectx.ui.custom()的 promise 并销毁组件);不要对ctx.ui.custom()创建的 overlay 调OverlayHandle.hide()。每个交互用新的组件实例。 - 主题:用回调传入的
theme,语义 token 如 accent / muted / success / warning / error / tool 输出 / Markdown。theme.style(text, { fg, bg, bold })组合前后景与属性;token 颜色要放到另一位置时传具体颜色(如{ fg: theme.colors.userMessageBg });theme.appearance("dark"/"light")用来决定提亮还是压暗。不要永久保存带主题颜色的字符串,除非invalidate()会重建它们(主题切换会清渲染缓存,但清不掉嵌进应用状态里的旧 ANSI 颜色)。渲染 Markdown 用getMarkdownTheme()。 - 性能:渲染在交互路径上;按宽度与内容缓存昂贵布局/高亮,并在
invalidate()里清缓存。默认视图保持紧凑,细节靠展开或专用屏幕。渲染问题用PI_TUI_WRITE_LOG抓原始 ANSI 流;要测窄宽度、宽字符、resize、主题切换、焦点切换,以及普通/全屏两种模式。
10. 官方示例参考
| 示例 | 覆盖点 |
|---|---|
hello.ts | 最小命令 |
dynamic-tools.ts | 动态激活工具 |
truncated-tool.ts | 大结果截断 |
debug-provider.ts | provider_stream_event 查看器(按助手消息分组原始事件) |
confirm-destructive.ts / dirty-repo-guard.ts | 危险操作确认、路径保护 |
custom-footer.ts / modal-editor.ts / widget-placement.ts | 替换 footer / 编辑器、常驻 widget |
doom-overlay/ | 持续渲染的 overlay |
preset.ts / tools.ts / qna.ts | 选择与设置列表、可取消异步 UI |
custom-compaction.ts | 自定义压缩 |
git-checkpoint.ts / auto-commit-on-exit.ts | 会话事件 + 外部副作用 |
完整清单见 examples/extensions/。
溯源
- 官方
docs/extensions.md、docs/tui.md,与examples/extensions/下的对应示例