第 11 讲扩展系统:自定义工具、事件拦截与 UI 集成
Extension 是 Pi 最强的定制机制:一个默认导出的工厂函数,拿到 ExtensionAPI 就能订阅全部生命周期事件、给 LLM 注册新工具、拦截 tool_call、绘制 TUI 组件并把状态写进会话日志。
§11.0学习目标
掌握扩展的加载模型:默认导出工厂、进程内全权限的信任前提、~/.pi/agent/extensions/ 与 .pi/extensions/ 的发现规则、-e 临时加载与 /reload 热重载。
背下 ExtensionAPI 的核心方法地图(on / registerTool / registerCommand / appendEntry / setModel …)并理解 8 大类事件与处理器的六种组合语义。
精读 tool_call 拦截的 block 语义与源码消费点,能写出权限门(rm -rf 确认、.env 路径保护)这类真实可用的扩展。
会用 ctx.ui 的完整交互面板(notify / confirm / select / input / setStatus / setWidget / custom),用 appendEntry 做跨会话状态,并理解 print / JSON / RPC 模式下的降级。
§11.1扩展模型:一个默认导出的工厂函数
先用一句话类比:Extension 就是一个被 Pi 在启动时调用一次的 TypeScript 插件模块——它默认导出一个工厂函数,Pi 把 ExtensionAPI 实例作为参数递进来,你在函数体里「订阅事件、注册工具、注册命令」,函数返回后插件挂载完毕。没有清单文件、没有注解扫描、没有单独进程,一个 .ts 文件就是全部。
官方文档 packages/coding-agent/docs/extensions.md(本讲最重要的单篇文档,全文 2500 余行)开篇的 Quick Start 浓缩了全部四种能力:
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
// 1. 订阅事件
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
// 2. 拦截 tool_call,可以 block
pi.on("tool_call", async (event, ctx) => {
if (isToolCallEventType("bash", event) && event.input.command.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
// 3. 注册 LLM 可调用的自定义工具
pi.registerTool({
name: "greet", label: "Greet", description: "Greet someone by name",
parameters: Type.Object({ name: Type.String({ description: "Name to greet" }) }),
async execute(toolCallId, params, signal, onUpdate, ctx) {
return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {} };
},
});
// 4. 注册斜杠命令
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => { ctx.ui.notify(`Hello ${args || "world"}!`, "info"); },
});
}
三个必须刻进肌肉记忆的事实:
- 加载器是 jiti。文档写明「Extensions are loaded via jiti, so TypeScript works without compilation」——
loader.ts用createJiti动态 import 扩展文件并取default导出,直接写 TS 即可,无需预编译。若模块没有导出函数,加载结果记录错误:Extension does not export a valid factory function。 - 扩展与 Pi 共享同一套类与注册表。
virtual-modules.ts把@earendil-works/pi-coding-agent、pi-ai、pi-tui、typebox等包名映射到 Pi 自带的副本(旧包名@mariozechner/*、@sinclair/typebox也同样映射),文档强调「extensions share Pi's classes and registries」——你做instanceof检查时面对的是同一个类,不会出现双份 registry。 - 扩展运行在 Pi 进程内,拥有启动用户的完整操作系统权限。文档安全提示原话:An extension runs inside the Pi process with the same operating-system permissions. It can inspect prompts, tool calls, files, credentials, and session history. 这不是沙箱、不是降权 worker——「只装可信来源的扩展」是唯一的信任边界。
Extension 最接近「Spring Boot Starter + @EventListener + Filter 链」的合体:pi.on() 是 ApplicationEventPublisher 订阅,tool_call 拦截是 Filter,registerTool 是向 SPI 注册新实现。但关键差别是进程与类加载器:Spring 插件至少还有模块边界,而 Pi 扩展与宿主在同一个 Node 进程、同一套模块注册表里运行,没有任何隔离层——等于把第三方 jar 直接放进你的 classpath 根部并以你的全部权限运行。安全模型的分量完全落在「你是否信任这段代码」上。
§11.2加载位置、发现规则与热重载
扩展的放置位置决定它是否被自动发现、是否需要项目信任、能否热重载。官方对照表:
| 位置 | 作用域 / 说明 |
|---|---|
~/.pi/agent/extensions/*.ts 或 *.js | 全局:对所有项目生效 |
~/.pi/agent/extensions/*/index.ts 或 index.js | 全局:子目录形式的多文件扩展 |
~/.pi/agent/extensions/*/package.json(含 pi.extensions 字段) | 全局:带清单的扩展包,可声明多个入口 |
.pi/extensions/… | 项目本地:同上三种布局;仅在项目通过信任后加载 |
pi -e ./path.ts | CLI 临时加载,官方定位是「quick tests」;不在自动发现位置则不能 /reload |
settings.json 的 packages / extensions | 额外路径:npm:@foo/bar@1.0.0、git:github.com/user/repo@v1 或本地路径 |
发现规则在 loader.ts 的 discoverExtensionsInDir() 中实现,源码注释概括为三条,且「No recursion beyond one level」——目录只向下探一层,更大的包必须用 package.json 清单声明入口:
/**
* Discovery rules:
* 1. Direct files: `extensions/*.ts` or `*.js` → load
* 2. Subdirectory with index: `extensions/*/index.ts` or `index.js` → load
* 3. Subdirectory with package.json: `extensions/*/package.json`
* with "pi" field → load what it declares
*
* No recursion beyond one level. Complex packages must use package.json manifest.
*/
discoverAndLoadExtensions() 的合并顺序值得记住(它决定了多扩展时事件处理器的执行顺序):① 项目本地 .pi/extensions/ → ② 全局 ~/.pi/agent/extensions/ → ③ settings.json / CLI 显式配置路径,全部按解析后的绝对路径去重后统一交给 loadExtensions()。
npm 依赖解析:在扩展目录(或其父目录)放一个 package.json,执行 npm install 后,node_modules/ 里的 import 会自动被解析;宿主提供的包(pi-coding-agent、pi-ai、typebox 等)不要写进 dependencies。多入口清单长这样:
{
"name": "my-extension",
"dependencies": { "zod": "^3.0.0", "chalk": "^5.0.0" },
"pi": {
"extensions": ["./src/index.ts"]
}
}
热重载:只有放在两个自动发现目录里的扩展能用 /reload 热重载。重载流程是完整的「关停—重建」:先向旧扩展实例派发 session_shutdown(reason: "reload"),重新加载资源,再派发 session_start(reason: "reload")与 resources_discover(reason: "reload")。所以内存态不会自动幸存——清理放 session_shutdown,重建放 session_start,这是所有有状态扩展的固定骨架。
另一个容易忽视的细节:工厂函数可能在从不开启会话的调用中运行(例如 pi --list-models)。文档明确禁止在工厂里启动后台资源(进程、socket、文件监听、定时器),必须延迟到 session_start 或真正用到它的命令/事件里,并注册幂等的 session_shutdown 清理。
§11.3ExtensionAPI 方法总览
ExtensionAPI 定义在 types.ts(约 1600 行处),实现则在 loader.ts 的 createExtensionAPI():注册类方法写入当前扩展对象,动作类方法委托给共享 runtime,runtime 未初始化时调用动作方法会抛错。按用途分组记忆:
| 分组 | 方法 | 一句话职责 |
|---|---|---|
| 事件订阅 | on(event, handler) | 订阅 40+ 生命周期事件;返回退订函数,仅移除该注册 |
| 工具 | registerTool() · getActiveTools() · getAllTools() · setActiveTools(names) | 注册 LLM 可调用工具;加载期与运行期都可用,动态注册立即生效、无需 /reload |
| 命令 / 键位 / 旗标 | registerCommand() · registerShortcut() · registerFlag() · getFlag() · getCommands() | 注册 /cmd 斜杠命令、键盘快捷键、CLI 旗标;同名命令共存时按加载顺序编号 /review:1、/review:2 |
| 消息注入 | sendMessage() · sendUserMessage() | 注入 custom 消息(进 LLM 上下文)或发送用户消息;支持 deliverAs: steer / followUp / nextTurn 与 triggerTurn |
| 状态持久化 | appendEntry(customType, data) | 把扩展数据写入会话 JSONL;custom 条目不进 LLM 上下文,可配 registerEntryRenderer() 在 TUI 里渲染 |
| 渲染定制 | registerMessageRenderer() · registerEntryRenderer() · registerToolRenderer() · registerMarkdownTransformer() | 分别接管 custom 消息、custom 条目、任意工具调用、Markdown 渲染 |
| 模型控制 | setModel() · getThinkingLevel() · setThinkingLevel() | 切换模型/思考级别;变更记入会话历史,恢复会话时还原 |
| Provider / MCP | registerProvider() · registerVirtualModel() · registerMcpServer() 及对应 unregister | 动态注册模型 Provider、虚拟模型(按请求路由到物理模型)、MCP 服务器 |
| 进程 / 会话 | exec() · setSessionName() · setLabel() · getSettings() · events | 无 shell 执行命令;会话命名与条目标签;读合并后设置;扩展间事件总线 |
其中 exec() 的结果形状是 { stdout, stderr, code, killed, truncated }:超时、中止或超过 maxBuffer(默认 256 MiB)都会置 killed: true,命令忽略 SIGTERM 时 5 秒后升级为 SIGKILL——比手写 child_process 安全得多,官方示例(git-checkpoint 等)一律走它。
§11.4事件目录与处理器组合
Pi 的事件面非常宽,但可以按「发生在哪个阶段」分成八类。先看目录,再看本讲的核心图示——扩展生命周期与事件流总图。
① 启动 Startup
进程启动与项目信任决策
project_trust可接管- 仅全局与
-e扩展参与;返回{trusted, remember},第一个 yes/no 获胜
② 资源 Resource
技能 / 提示词 / 主题 / MCP 资源贡献
resources_discover(在 session_start 之后)mcp_servers_change
③ 会话 Session
会话生命周期与结构操作
session_start/session_shutdownsession_before_switch/session_before_fork可取消session_before_compact/session_compact/session_compact_failedsession_before_tree/session_tree/session_info_changed
④ Agent 运行
一次 run 与其中的 turn
before_agent_start(注入消息/改系统提示)agent_start/agent_endagent_before_settle/agent_settledturn_start/turn_end(边界,可请求续跑)
⑤ 模型 / 请求 Model
每次 LLM 请求的钩子链
context→context_with_systembefore_provider_headers(就地改请求头)before_provider_request(替换 payload)after_provider_response/provider_stream_eventmessage_start / update / endmodel_select/thinking_level_selectcache_warming_decision
⑥ 工具 Tool
每个 tool call 的执行钩子
tool_execution_starttool_call可 block / 就地改 inputtool_execution_update(执行中)tool_result链式改写结果tool_execution_end(每个终结调用必有)
⑦ 用户 bash
! / !! 内嵌命令
user_bash可拦截:返回{operations}换执行后端(SSH/沙箱),返回{result}直接记录结果,返回undefined落回本地执行
⑧ 输入 Input
用户输入进入 Agent 前
input(扩展命令之后、技能/模板展开之前;返回handled / transform / continue)ui_prompt_start/ui_prompt_end(阻塞式 UI 提示的包裹通知)
docs/extensions.md 的 Lifecycle Overview 三张官方 mermaid 图与 loader.ts、runner.ts 源码整理。官方三图分别覆盖:一次运行的输入 → run → turn 结构、每个 turn 内模型请求与工具调用的钩子顺序、会话启动 / 替换 / 退出的独立生命周期。处理器组合:六种语义,决定你的返回值怎么被对待
多个扩展订阅同一事件时,处理器按「扩展加载顺序 → 扩展内注册顺序」执行。但返回值如何组合,事件与事件不同——文档概括为六种模式,写扩展前必须对号入座:
| 模式 | 行为 | 代表事件 |
|---|---|---|
| 通知 Notification | 返回值被忽略,仅观察 | agent_settled、provider_stream_event、session_compact |
| 就地修改 In-place mutation | 后序处理器看到你改过的值 | before_provider_headers.headers、before_agent_start.systemPromptOptions、tool_call.input |
| 链式转换 Chained | 后面的处理器收到前面返回的新值 | input、context、context_with_system、message_end、tool_result、before_provider_request |
| 门禁 Gate | 第一个「已处理 / 已阻止」的结果终止正常回退路径 | input 的 handled、tool_call 的 block、会话操作的 cancel、user_bash 的 {operations}/{result} |
| 可操作边界 Boundary | 每个处理器看到此前累计的 entries 提案与续跑决策 | turn_end、agent_before_settle |
| 最后覆盖 Last override | 最后一个返回 action 的处理器获胜 | cache_warming_decision |
两条官方工程箴言:通知型处理器不要在流式事件上做慢活(provider_stream_event 的处理器按流序 await,慢了会拖慢整个流消费);门禁型处理器在无法做出安全判断时应 fail closed(宁可阻止)。
§11.5tool_call 拦截与 block 语义
tool_call 在 tool_execution_start 之后、工具真正执行之前触发,是扩展系统最高频的用途——权限门、路径保护、命令改写都挂在这里。官方的行为保证有五条,全部值得逐字记住:
event.input是可变的:就地修改会真实作用于工具执行;- 后序
tool_call处理器能看到先序处理器的修改; - 修改后不会重新做参数校验(pi-schema 的 JSON Schema 校验发生在你之前);
- 返回值通过
{ block: true, reason?, terminate? }控制拦截; terminate只对被阻止的调用生效,且只有当本批全部终结结果都 terminate 时才会让 agent 提前停止。
block 之后发生什么?很多人以为循环会中断——不会。看内核源码的消费点:被阻止的调用被包装成一个错误工具结果回填,循环照常继续,模型会读到这条错误并自行决定下一步(道歉、换方案或再次尝试):
if (beforeResult?.block) {
const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
if (beforeResult.terminate === true) {
result.terminate = true;
}
return { kind: "immediate", result, isError: true };
}
/**
* Returning `{ block: true }` prevents the tool from executing. The loop emits
* an error tool result instead. `reason` becomes the text shown in that error
* result. If omitted, a default blocked message is used.
*/
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
/** Early termination only happens when every finalized tool result in the batch sets this to true. */
terminate?: boolean;
}
拦截器在 runner.ts 的 emitToolCall() 里串行执行:一旦某个处理器返回 block: true,立即返回,不再调用后续处理器——这就是「门禁」模式。官方最短而完整的权限门示例(注意它对无 UI 环境的 fail-closed 处理):
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = event.input.command as string;
const isDangerous = dangerousPatterns.some((p) => p.test(command));
if (isDangerous) {
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
}
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
if (choice !== "Yes") {
return { block: true, reason: "Blocked by user" };
}
}
return undefined;
});
}
路径保护是同一模式的变体,把检查对象从 bash 的 command 换成 write / edit 的 path:protected-paths.ts(同目录,约 27 行)对 .env、.git/、node_modules/ 直接返回 { block: true, reason: 'Path "..." is protected' },有 UI 时先 notify 提示用户。本讲动手实践会把它升级成「需确认」版本。
类型缩窄用官方类型守卫:内置工具直接 isToolCallEventType("bash", event),event.input 即获得精确类型;自定义工具导出 schema 的 Static 类型后用 isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)。另外两条容易被忽略的执行模型:并行工具模式下,同一 assistant 消息的兄弟工具调用会先串行预检(preflight)再并发执行,因此 tool_call 里不保证能从 ctx.sessionManager 看到兄弟工具的结果;tool_call 派发前,Pi 会先等此前发出的 Agent 事件排空,所以会话状态至少同步到当前这条 assistant 消息。
§11.6ctx.ui:扩展的完整交互面板
每个事件处理器的第二个参数 ctx: ExtensionContext 里,ctx.ui 是与用户交互的全部出口。接口定义在 types.ts 的 ExtensionUIContext,按能力分层:
| 能力层 | 方法 | 说明与可用模式 |
|---|---|---|
| 对话框(阻塞式) | select(title, options, opts?) | 单选;取消/超时返回 undefined |
confirm(title, message, opts?) | 确认;取消/超时返回 false | |
input(title, placeholder?, opts?) | 单行文本输入 | |
editor(title, prefilled?) | 多行编辑器 | |
| 通知 | notify(message, "info"|"warning"|"error") | 非阻塞提示 |
| 状态展示 | setStatus(key, text?) | 页脚持久状态槽,传 undefined 清除 |
setWidget(key, content?, opts?) | 编辑器上方(默认)/下方挂小部件;接受字符串数组或组件工厂,placement: "belowEditor" 可选 | |
setWorkingMessage() / setWorkingIndicator() / setHiddenThinkingLabel() / setTitle() / setHeader() / setFooter() | 流式加载文案与指示器、思考块标签、终端标题、启动头部与整条页脚替换 | |
| 编辑器与输入 | setEditorText() / getEditorText() / pasteToEditor() | 读写主输入框、以粘贴语义注入 |
onTerminalInput(handler) | 原始终端输入流;返回 {consume:true} 可吞掉按键(仅 TUI) | |
addAutocompleteProvider(factory) / setEditorComponent() / getEditorComponent() | 叠加自动补全;替换整个编辑器组件(Vim 模式等,扩展 CustomEditor) | |
| 全屏 / 覆盖层 | custom<T>(factory, opts?) | 获得键盘焦点的自定义 TUI 组件;支持 overlay 定位与 blocked 状态上报;仅 TUI,RPC 下返回 undefined |
对话框还支持 opts.timeout(带倒计时自动关闭)与 opts.signal(用 AbortSignal 区分超时与用户取消)。summarize.ts 展示了「命令 → 模型调用 → custom 全屏展示」的完整链路,其 UI 收尾段(节选):
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
if (ctx.mode !== "tui") { // 模式守卫:非 TUI 直接放弃渲染
return;
}
await ctx.ui.custom((_tui, theme, _kb, done) => {
const container = new Container();
// …DynamicBorder + Markdown(summary) 组装滚动视图…
return {
render: (width: number) => container.render(width),
invalidate: () => container.invalidate(),
handleInput: (data: string) => {
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
done(undefined); // 关闭全屏组件
}
},
};
});
};
ctx.ui 的角色与 Swing 的 EDT(Event Dispatch Thread)调度一致:你的扩展代码跑在 agent 的执行路径上,但用户可见的一切必须经宿主的渲染循环投递——ctx.ui.notify() 之于 SwingUtilities.invokeLater(),ctx.ui.custom() 之于把一个自定义 JComponent 交给 JFrame 并接管 KeyEvent。RPC 模式则像把显示器换成远程协议:调用仍可用,但渲染发生在客户端,组件工厂自然无从谈起;print/JSON 模式干脆没有显示器,对话方法需要先看 ctx.hasUI。
§11.7状态持久化与跨会话恢复
扩展状态有三层,语义各不相同,选错层是最常见的返工原因:
- 模块内存变量——最便宜,但
/reload、会话替换、进程退出后全部归零; - 工具结果
details——随 toolResult 消息进入会话 JSONL,天然跟随分支(branch)语义,适合「业务状态」:恢复时扫描当前分支ctx.sessionManager.getBranch()而非getEntries()(被放弃的分支是平行历史,不应参与重建); pi.appendEntry(customType, data)——把任意 JSON 作为 custom 条目写进会话日志:不进 LLM 上下文、不影响模型行为,可配registerEntryRenderer()在 TUI 里画出状态卡片,重启/恢复会话后仍在。
appendEntry + 恢复的官方骨架:
pi.appendEntry("my-state", { count: 42 });
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
// Restore on reload / resume / fork
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === "my-state") {
// Reconstruct from entry.data
}
}
});
import { Box, Text } from "@earendil-works/pi-tui";
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
const data = entry.data as { title: string; count: number };
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
if (expanded) {
box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
}
return box;
});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
这正是事件溯源(Event Sourcing)的分层:appendEntry 写的是「不参与业务决策的审计事件」,tool_result.details 是「进聚合重放的业务事件」,内存变量只是本进程的缓存。重建状态要用 getBranch()(当前分支 = 聚合的有效历史),不能拿全量 getEntries() 重放——后者把所有被放弃的分支也算了进去,相当于用失败事务的旧值覆盖最终状态。
§11.8四种运行模式下的降级行为
同一份扩展代码会跑在四种模式里,可用能力完全不同。两张守卫牌必须形成条件反射:ctx.hasUI(TUI 与 RPC 为 true)守对话框与通知;ctx.mode === "tui" 守 custom()、组件工厂、原始终端输入这类纯终端能力。
| 模式 | ctx.mode | ctx.hasUI | 行为要点 |
|---|---|---|---|
| 交互式(默认) | "tui" | true | 完整终端渲染,所有 ctx.ui 能力可用 |
RPC(--mode rpc) | "rpc" | true | 对话框与通知经 JSON 协议转发给 RPC 客户端渲染;custom() 返回 undefined,组件工厂被忽略(详见 rpc-extension-ui.md) |
JSON(--mode json) | "json" | false | 事件流写 stdout,UI 方法全部 no-op |
Print(-p) | "print" | false | 扩展照常运行(拦截、注入仍有效),但无法向用户提问 |
所以「print 模式为什么不能弹 confirm」的答案是结构性的:对话框需要一个能等待人类输入的通道,print/JSON 模式的 stdout 是给程序消费的流,没有交互回路。正确做法就是 permission-gate.ts 的写法——if (!ctx.hasUI) return { block: true, reason: "… (no UI for confirmation)" }:无法确认时按拒绝处理,宁可保守。同理,summarize.ts 的全屏 UI 前有 ctx.mode !== "tui" 提前返回,git-checkpoint 在无 UI 时跳过「是否恢复代码」的询问而不自动恢复。
§11.9官方示例地图:三个代表精读
examples/extensions/ 目录是活的 API 文档(docs 末尾的 Examples Reference 按用途索引了全部 60+ 个示例)。本讲精读的三个代表分别覆盖「拦截」「状态 + 命令」「TUI 深水区」:
| 示例 | 演示能力 | 关键 API |
|---|---|---|
permission-gate.ts(33 行) | 权限门:正则识别 rm -rf/sudo/chmod 777,select 确认,无 UI fail-closed | on("tool_call")、ctx.ui.select、ctx.hasUI、{block, reason} |
git-checkpoint.ts(51 行) | git 检查点:每 turn git stash create 存快照,fork 时询问是否恢复 | on("turn_start")、pi.exec、内存 Map + entryId 关联、session_before_fork、agent_settled |
summarize.ts(约 160 行) | 会话总结命令:扫分支消息 → 调模型 → custom 全屏 Markdown 展示 | registerCommand、ctx.modelRegistry.complete、ctx.ui.custom、matchesKey |
const checkpoints = new Map<string, string>();
let currentEntryId: string | undefined;
pi.on("tool_result", async (_event, ctx) => {
const leaf = ctx.sessionManager.getLeafEntry();
if (leaf) currentEntryId = leaf.id;
});
pi.on("turn_start", async () => {
// Create a git stash entry before LLM makes changes
const { stdout } = await pi.exec("git", ["stash", "create"]);
const ref = stdout.trim();
if (ref && currentEntryId) {
checkpoints.set(currentEntryId, ref);
}
});
pi.on("session_before_fork", async (event, ctx) => {
const ref = checkpoints.get(event.entryId);
if (!ref) return;
if (!ctx.hasUI) return; // 非 TUI 不自动恢复
const choice = await ctx.ui.select("Restore code state?", [
"Yes, restore code to that point", "No, keep current code",
]);
if (choice?.startsWith("Yes")) {
await pi.exec("git", ["stash", "apply", ref]);
ctx.ui.notify("Code restored to checkpoint", "info");
}
});
想继续深入,按主题再挑三个即可:custom-compaction.ts(接管压缩摘要)、snake.ts(约 300 行,定时器 + 键盘 + 组件缓存的全功能 TUI 游戏,还演示了保存游戏状态并在重启后恢复)、with-deps/(带 npm 依赖的扩展包目录结构)。plan-mode/ 与 gondolin/ 是两个「扩展能走多远」的极限案例,留到第 13、14 讲再用。
§11.10动手实践:写一个真实可用的 review-gate 扩展
综合任务来自课程大纲:拦截对 .env 与 node_modules 的写入需确认;注册 /review-checklist 命令;用 appendEntry 把每次拦截持久化;会话重启后恢复记录。按下面的顺序搭建:
- 建文件并快速试载
创建
.pi/extensions/review-gate.ts(项目内,需项目信任)或~/.pi/agent/extensions/review-gate.ts(全局)。开发期用pi -e ./review-gate.ts临时加载最快;放入自动发现目录后改用/reload热重载,验证「shutdown → 重挂载 → session_start」全流程。 - tool_call 拦截 + 模式降级
订阅
tool_call,用isToolCallEventType("write", event)与isToolCallEventType("edit", event)缩窄类型;路径含.env或node_modules/时:有 UI 用ctx.ui.confirm("写保护", path)询问,拒绝则返回{ block: true, reason: "Protected path denied: " + path };无 UI(print/JSON)直接 fail-closed 阻止。对照 permission-gate.ts 的分支结构。 - 注册命令与状态展示
pi.registerCommand("review-checklist", { … }):handler 里用ctx.ui.notify()打印检查清单,并用ctx.ui.setStatus("review-gate", "已拦截 N 次")在页脚常驻一个计数。 - 持久化拦截日志
每次拦截发生(无论放行还是阻止)调用
pi.appendEntry("review-gate-log", { path, decision, reason, at: Date.now() });再registerEntryRenderer("review-gate-log", …)让它在 TUI 会话流里渲染成一行简报。 - 跨会话恢复
在
session_start里遍历ctx.sessionManager.getEntries(),过滤entry.type === "custom" && entry.customType === "review-gate-log"重建内存计数,然后ctx.ui.setWidget("review-stats", ["拦截统计", "共 N 次 · 阻止 M 次"])显示在编辑器上方。退出 Pi、pi --resume回到该会话,验证 widget 数字仍在。 - 模式回归验证
分别用
pi(TUI)、pi -p "向 .env 写入 API_KEY"(print)、pi --mode json各跑一次:确认 TUI 弹确认、print/JSON 静默阻止且事件流里能看到错误 toolResult。最后/reload两次确认无泄漏定时器。
§11.11常见误区
误区一:在工厂函数里启动 watcher / 定时器 / 子进程
工厂可能在从不开启会话的调用(如 pi --list-models)中运行。后台资源必须在 session_start(或真正需要它的命令/事件)里启动,并注册幂等的 session_shutdown 清理;snake.ts 的游戏定时器就挂在组件生命周期而非工厂里。
误区二:以为扩展有沙箱或降权
扩展与 Pi 同进程、同 OS 权限,能读提示词、工具调用、文件、凭据与会话历史。一个恶意 .pi/extensions/ 文件在项目通过信任后即获得你账户的全部能力——「只装可信来源」是唯一的防线,第 14 讲的安全部分会展开。
误区三:mutate tool_call.input 后期待重新校验,或忽略后续处理器能看到改动
官方保证是「不 re-validation」且「later handlers see mutations made by earlier handlers」。改写参数要自己保证合法性;多个扩展同时改写同一工具参数时按加载顺序叠加,改动尽量收敛在自己关心的字段上。
误区四:捕获旧的 pi/ctx 跨 newSession()/fork()/switchSession()/reload() 使用
会话替换后旧 runtime 被标记 stale,再调用会抛错(runtime 的 invalidate() 会在错误信息里明确提示)。替换后的工作必须写进 withSession 回调并用传入的新 ctx;此前捕获的裸对象(如旧 sessionManager)同样作废,只允许带出字符串、id 等可序列化数据。
误区五:无 UI 模式下调用 confirm/select 然后「等待」
print/JSON 模式 ctx.hasUI === false,没有交互回路。门禁逻辑必须先判 ctx.hasUI,无 UI 时给出保守默认值(阻止/跳过),像 permission-gate 与 git-checkpoint 那样。
§11.12自测问答
Q1tool_call 事件返回 { block: true, reason } 之后,Agent 循环会发生什么?
工具不会执行。agent-loop.ts 用 createErrorToolResult(reason || "Tool execution was blocked") 生成一个 isError: true 的 toolResult 立即回填,循环继续——模型会读到这条错误并自行决定下一步。若同时 terminate: true,则只有当本批所有终结结果都 terminate 时 agent 才提前停止。派发层面,runner.emitToolCall() 一旦遇到 block 结果立即返回,后续扩展的 tool_call 处理器不再执行。
Q2扩展状态如何跨会话(重启 / resume / fork)存活?
三条路:① 业务状态放进自定义工具结果的 details,随 toolResult 消息持久化,恢复时按当前分支 getBranch() 重放;② 审计/展示类数据用 pi.appendEntry(customType, data) 写成 custom 条目(不进 LLM 上下文),配 registerEntryRenderer 渲染;③ 真正落盘的自有文件(自行读写)。无论哪种,重建都发生在 session_start(reason 覆盖 startup/reload/new/resume/fork),清理发生在 session_shutdown;纯内存变量在重载/替换后必然丢失。
Q3为什么扩展不能在 print 模式弹 confirm?
print(-p)与 JSON 模式下 ctx.hasUI === false:stdout 是供程序消费的一次性输出流,没有等待人类输入的回路,对话框方法不可用。正确做法是模式守卫:无 UI 时按保守策略处理(如 permission-gate 直接 block 并写明 "no UI for confirmation")。TUI 与 RPC 模式 hasUI === true,对话框可用,但 RPC 下 custom() 返回 undefined、组件工厂被忽略。
Q4多个扩展订阅同一事件时,执行顺序与结果组合规则是什么?
顺序固定为「扩展加载顺序(项目本地 → 全局 → 显式配置,目录内按发现顺序)→ 扩展内注册顺序」。组合语义按事件分六种:通知(忽略返回值)、就地修改(headers / systemPromptOptions / tool_call.input)、链式转换(input / context / context_with_system / message_end / tool_result / before_provider_request)、门禁(第一个 block / handled / cancel 即终止)、可操作边界(turn_end、agent_before_settle 逐个看到累计提案)、最后覆盖(cache_warming_decision)。写处理器前先确认目标事件属于哪一类。
Q5pi.appendEntry() 与 pi.sendMessage() 都能往会话里写东西,区别是什么?
sendMessage 注入的是 custom 消息:进入会话历史并参与 LLM 上下文(模型能看到),可设 display 控制是否在 TUI 显示,配 registerMessageRenderer 渲染,还能 triggerTurn: true 触发一次模型响应。appendEntry 写的是 custom 条目:持久化进会话 JSONL 但不发给模型,纯粹给 TUI/导出/恢复用,配 registerEntryRenderer 渲染。一句话:要影响模型用前者,只记状态用后者。
Q6扩展的 npm 依赖是如何被解析的?宿主提供的包能写进 dependencies 吗?
在扩展目录(或父目录)放 package.json 并 npm install,jiti 会就近解析 node_modules/ 里的 import;多入口用 pi.extensions 字段声明。宿主已内置的包(@earendil-works/pi-coding-agent、pi-agent-core、pi-ai、pi-tui、typebox,以及旧的 @mariozechner/*、@sinclair/typebox 名字)被映射到 Pi 自带副本、与宿主共享类和注册表,不应写进 dependencies;通过 pi install 分发的包,运行时依赖必须放 dependencies(devDependencies 不会安装)。