扩展开发
41 个生命周期事件、工具暴露模型,以及扩展为什么等于进程内代码
前面几篇都在讲 Pi 内置了什么:8 个工具、4 个默认启用、MCP 已经内置、Codemode 是个扩展。这一篇反过来,从零写一个扩展。
写完你会得到三样东西:一个 ~/.pi/agent/extensions/xxx.ts 文件就能跑起来的最小闭环;一张「我想做的事该注册到哪」的对照表;以及一份关于「装扩展」这件事到底意味着什么的清醒认识。
先说结论,它是全文最容易被跳过、也最不该被跳过的一节。
10.1 第一个扩展
一个扩展长什么样
Pi 对扩展的形态要求只有一条:默认导出一个工厂函数,接收 ExtensionAPI。
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");
},
});
}这几行就是全部。没有基类要继承,没有 manifest 要填,没有打包要构建。把文件丢进 ~/.pi/agent/extensions/hello.ts,启动 Pi,敲 /hello。
这个设计值得停下来想一想为什么。工厂函数而非模块顶层副作用,意味着加载时机由 Pi 掌握:Pi 决定什么时候调用你的函数、传入哪个 pi 对象、在什么上下文里跑。你在顶层起进程、注册全局钩子,都是绕过这个约定。
为什么不用编译
Pi 用 jiti 加载扩展,直接消费 .ts 源码。源码里 src/core/extensions/jiti-loader.ts 只有三行,转出的就是 jiti 的 createJiti:
// Normal Node runtimes use jiti's lazy transform so Babel is loaded only when
// native loading fails and an extension needs transformation.
export { createJiti } from "jiti";「lazy transform」的注释值得注意:新版本 Node 能直接剥离类型标注时不会拉 Babel,只有原生加载失败才启用转换层。也就是说扩展的启动成本取决于你的文件有多复杂,而不是 Pi 有多重。
对写扩展的人,这意味着迭代闭环是改文件 → /reload,没有 watch、没有 tsc、没有 dist。要发布给别人用,走的是 Pi 包 那条路,那是另一件事。
开发期最常用的是 --extension(短选项 -e)直接指一个文件:
pi --extension ./hello.ts配套的开关是 --no-extensions / -ne,关掉扩展发现和内置扩展,但显式的 -e 路径仍然生效。这让你能在排查「是不是某个扩展搞坏了」时把现场隔离干净。
一个真实一点的工具
上面那个例子演示了命令。工具是扩展最常做的事,写法也不复杂:
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const helloTool = defineTool({
name: "hello",
label: "Hello",
description: "A simple greeting tool",
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: { greeted: params.name },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(helloTool);
}这是仓库里 examples/extensions/hello.ts 的原样。有几点值得单独拎出来,因为它们是所有工具的通用契约:
defineTool 是一个纯类型辅助函数(src/core/extensions/types.ts:690),它原样返回对象,作用是让 execute 里的 params 被正确推断成你 schema 的形状。不调它也能工作,但参数会退化成 any。
parameters 用 TypeBox 而不是手写 JSON Schema。因为 Pi 拿到的所有供应商协议(10 种聊天 API 协议)都要从这一份 schema 出发做转换。
返回值必须同时有 content 和 details。content 是给模型看的文本(可以是图片),details 是给你自己的渲染器和状态重建用的。没有结构化细节时显式写 details: undefined,不要省略——省略和 undefined 在类型层面不是一回事。
execute 的五个参数是固定签名:(toolCallId, params, signal, onUpdate, ctx)。signal 是取消信号,onUpdate 用来推送部分结果(长任务别让模型干等),ctx 是 ExtensionToolContext——在普通 ExtensionContext 之上多了 tools 和 executeTool(),下一节展开。
label 是给 UI 用的,description 是给模型用的。这个区分在 Pi 里贯穿始终:凡是给模型看的字符串,都会在 token 预算里反复出现;凡是给人看的,随便改。
工厂函数里不要做的事
官方文档有一条听起来保守、实际很关键的规则:不要在工厂函数里启动进程、socket、watcher 或定时器。
export default function (pi: ExtensionAPI) {
const server = startSomething(); // 不要这样
pi.on("session_shutdown", () => server.close());
return server;
}理由是:有些调用路径会加载扩展但不启动会话。pi 的某些子命令、某些 SDK 用法只做资源加载就跑掉了。你在工厂里开的端口会跟着泄漏出去,而且没人会来关它。
正确的位置是 session_start,或者干脆等到真正需要它的那个命令/工具被调用时:
export default function (pi: ExtensionAPI) {
let server: Server | undefined;
pi.on("session_start", async () => {
server = startSomething();
});
// 关闭必须幂等:取消、重载、会话替换、进程退出
// 都可能收敛到同一条路径上
pi.on("session_shutdown", async () => {
server?.close();
server = undefined;
});
}工厂函数可以是同步的也可以是异步的。异步工厂会被 await 完才继续启动流程,这给了你一个拉配置、注册启动期必需的供应商的机会。如果你需要在启动期做点什么,这是唯一的窗口。
10.2 加载与发现
三个来源,一个顺序
discoverAndLoadExtensions()(src/core/extensions/loader.ts:828)按固定顺序收集路径,先到先得,同一个路径只取一次:
这里的 .pi 就是项目配置目录,CONFIG_DIR_NAME 的值。项目扩展排在全局扩展前面,因为它们的加载顺序决定 handler 的执行顺序(后面会讲为什么这很重要)。项目扩展要走 project trust 判定(见 06 工具与信任边界),全局扩展不需要。
目录里放什么算数
discoverExtensionsInDir()(src/core/extensions/loader.ts:791)只有三条规则,而且只扫一层,不递归:
extensions/
├── hello.ts → 直接加载
├── tools.js → 直接加载
├── my-extension/
│ ├── index.ts → 加载 index.ts
│ ├── utils.ts (不会被单独扫描)
│ └── package.json
└── with-deps/
└── package.json → 读 "pi.extensions" 字段,按声明加载第二层规则由 resolveExtensionEntries()(src/core/extensions/loader.ts:749)实现,判定顺序是:
- 目录里有
package.json且其中pi.extensions字段非空 → 按它声明的路径加载; - 否则有
index.ts→ 加载它; - 否则有
index.js→ 加载它; - 都没有 → 这个目录不是扩展。
「不递归超过一层」是刻意的。复杂的包必须用 package.json manifest 显式声明入口——递归扫描会得到一个无法预测的加载顺序,而加载顺序会决定同名工具谁赢。
要带 npm 依赖,就在扩展目录里放个 package.json,依赖装在那一层。
加载之后
工厂返回之后,Pi 还会按情况做几件事:
- 注册期调用
pi.registerProvider()会被排队,等 runner 绑定好 context 之后再应用。之后再调用就立即生效——所以从命令处理器或事件回调里改供应商不需要/reload。 - 注册期调用的
pi.registerMcpServer()会在session_start时和mcp.json里的服务器一起连接。加载之后才注册的则立即连接。 - MCP 注册不会持久化。每次加载都要重新注册,想要动态化就自己读扩展的配置。
/reload 会整体替换扩展运行时。官方文档里那句提醒值得抄下来贴墙上:
Reload replaces the extension runtime, so code after
await ctx.reload()must not reuse state from the old runtime.
await ctx.reload() 之后的代码,跑在一个全新的运行时里。你在旧运行时上攒的闭包变量、Map、连接池,全都作废。想跨越重载,得把状态写到会话条目或外部存储里。
resources_discover:运行时追加资源路径
如果你的扩展要提供额外的 skill、prompt 或 theme 目录,不需要改配置文件,可以在 session_start 之后接住这个事件:
pi.on("resources_discover", async (event) => {
return {
skillPaths: [join(homeDir(), ".claude/skills")],
promptPaths: [],
themePaths: [],
};
});reason 字段是 "startup" 还是 "reload",两种情况都会触发。注意这个事件排在 session_start 之后,所以它改的是已经启动的这次会话的资源,不是下一次。
project_trust:谁能参与启动期判定
这是一个容易忽略的边界。project_trust 事件在项目扩展加载之前触发,用于决定项目资源要不要加载。但它不是所有扩展都能参与的:
Only personal and explicit command-line extensions can participate in the
project_trustevent that runs before project extensions load.
也就是说,只有全局扩展和命令行显式指定的扩展能在这个时点插手。项目扩展自己还没被加载,它当然也没法决定自己要不要被加载——这个顺序是自洽的,不是 bug。
ProjectTrustEventResult 很简单:{ trusted: "yes" | "no" | "undecided", remember?: boolean }。
三种模式都能加载
扩展在交互式、RPC、JSON、print 四种模式下都会加载。四种模式的差别在 UI:
ExtensionMode 这个联合类型定义在 src/core/extensions/types.ts:323。
这带来一条硬性纪律:工具行为和事件处理必须与渲染解耦。一个只在 TUI 下能跑的工具,在 CI 里 --mode json 调用就会炸。判断条件分开写:
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const choice = await ctx.ui.select("Allow?", ["Yes", "No"]);
// ...
});permission-gate.ts 里的写法更完整——它显式检查了 ctx.hasUI:
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" };
}
}「没有 UI 就默认拒绝」是这个场景下唯一安全的默认值。换成默认放行,你就在 CI 里开了一个后门。
10.3 注册工具
工具是扩展最重要的产出,值得花最多篇幅。
ToolDefinition 的字段地图
定义在 src/core/extensions/types.ts:579。按「给谁看」分组:
promptSnippet 和 promptGuidelines 经常被漏掉,但它们直接影响模型表现。 默认系统提示里有一段「可用工具」,如果你的工具没有 promptSnippet,它就不会出现在那一段里——模型只能从工具列表的 description 得知它存在。想要更好的效果,就补上。
暴露模型:五个值,一个都不能理解错
这是扩展开发里最容易搞反的一块。ToolExposure 定义在 src/core/extensions/types.ts:516:
export type ToolExposure = "direct" | "model-only" | "codemode" | "deferred" | "hidden";配上 src/core/extensions/types.ts:505 的原始注释,语义可以这样读:
direct——同时对模型可见、对其他工具可调用。这是默认值,也是绝大多数工具该用的值。model-only——只对模型可见。用于编排型工具和需要向用户提问的工具:它们是入口,不应该被别的工具调用,否则会绕开交互逻辑。codemode——注册即可被ctx.executeTool()调用,但不会自动声明给模型。Codemode 工具会在自己的描述里列出它们。deferred——同上,但 codemode 的工具列表里也不列。只有tool_search能找到并激活它。这是为「工具很多、不想全塞进上下文」准备的。hidden——注册了,但哪儿都够不到。激活它没有任何效果。
三条推论,每条都会咬人:
第一,注册 direct 或 model-only 的工具会被激活;其余 exposure 不会。
// types.ts:513
// `direct` and `model-only` tools are activated when they are registered; the others are not.这个行为在写「加载器工具」时是刚需:先把所有工具都注册上,让可选的那些保持未激活,然后用一个 loader 工具调 pi.setActiveTools() 挑出需要的组合。setActiveTools() 的名字必须是已经注册过的,未知名字会被忽略。
第二,工具无法注销。
// types.ts:511
// - `hidden`: registered but unreachable. Activating it has no effect.官方文档把这写得更直接:"Registered but unreachable. Re-register a tool with exposure: \"hidden\" to withdraw it, since tools cannot be unregistered."
所以「下架一个工具」的正确操作是用同名工具重新注册一次,这次给 exposure: "hidden"。这不是绕路,这是唯一路径。理解这一点,你就知道为什么 registerTool 没有 unregisterTool——注册表在整个运行时生命周期里只增不减。
第三,setActiveTools() 忽略 hidden 工具,源码注释写得很清楚(src/core/extensions/types.ts:1775):"Unknown and hidden tools are ignored. Tools with codemode or deferred exposure stay callable from codemode scripts whether active or not."
annotations:给权限扩展的提示
ToolAnnotations(src/core/extensions/types.ts:522)是四个布尔提示,语义与 MCP 的 tool annotations 一致:
这些提示来自工具作者,不被验证。它们的价值是让一个权限扩展能写出一句可读的策略。官方文档给的例子(docs/extensions.md:167):
pi.on("tool_call", async (event, ctx) => {
const hints = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
const needsApproval =
hints?.destructiveHint === true ||
(!hints?.readOnlyHint && ((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
if (needsApproval && !(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
return { block: true, reason: `${event.toolName} was not approved` };
}
});注意那个 ?? true:缺失的 hint 按 MCP 默认值算,即「不是只读的」。这个默认值是保守的,但它的代价是你必须给每个工具显式标 readOnlyHint: true,否则一条无害的读操作也会弹确认框。MCP 服务器带过来的工具则直接沿用服务器声明的 hint。
如果你要给内置工具补标注,最简单的办法是用 tool-override.ts 那套:注册一个同名工具把内置的顶掉。
namespace:把一堆工具打包
ToolNamespace(src/core/extensions/types.ts:534)模仿 MCP server 的分组:
pi.registerTool({
name: "mcp__jira__create_issue",
// ...
namespace: {
name: "mcp__jira",
aliases: ["jira"],
description: "Jira issue management",
instructions: "Long-form usage guidance, not listed in tool listings.",
},
});四个字段的分工:
name——分组名,MCP 里通常是mcp__<server>;aliases——脚本按别的名字查这个 namespace,比如服务器叫docs时可以用docs查到mcp__docs;description——在模型可见的工具列表里,整组只出现一次,配这个摘要;instructions——不出现在任何列表里,只有 codemode 脚本主动调describeNamespace(name)才拿得到。
instructions 的存在是个有意思的设计判断:长文本要能被取到,但不能默认消耗上下文。
reachesTools 与 prepareLoadout:编排型工具的两个钩子
这两个字段都是为「一个工具去调度别的工具」的场景准备的,codemode 和 tool_search 是内置的范例。
reachesTools(input) 声明这次调用可能触达哪些工具:
reachesTools?: (input: Record<string, unknown>) => "all" | readonly string[];返回 "all" 表示全部,或者返回 input 里可能命名到工具的那些词。一个「词」匹配所有名字包含它的工具或 namespace——所以词可以匹配过宽,源码注释直说了这点(types.ts:617):
where a word refers to a tool or namespace whose name it contains, so words may over-match.
codemode 的策略是:脚本如果在做搜索或列举,返回 "all";否则返回脚本里的词。tool_search 永远返回 "all"。
它的实际用途在晚注册的场景。一个扩展在 tool_call 处理器里检查 reachesTools,如果发现这次调用需要的工具还没注册好,就先补注册再让它跑——MCP 内置扩展对「还在连接中的服务器」就是这么干的。
prepareLoadout(loadout) 更进一步,它让工具在激活期间修改模型看到的工具视图:
prepareLoadout?: (loadout: ToolLoadout) => ToolLoadoutChanges | undefined;ToolLoadout(types.ts:552)给了它三份清单:declared(激活的、声明给模型的)、callable(ctx.executeTool() 能调的)、registered(全部),以及 getExposure() / getNamespace() / getPromptGuidelines() 三个查询。返回值可以:
- 替换
descriptions——包括它自己的描述,用来把可调用的工具列进去; - 声明
hiddenDeclarations——这些工具保持激活、可被调用,但模型看不到它们的声明。
第二条有个容易忽略的后果,源码注释写得很清楚(types.ts:570):
Declared tools whose declarations requests leave out. They stay active and callable, and the transcript still declares them, so the active set survives
/treeand resume.
它们仍然被写进 transcript。所以 active 集合能扛过 /tree 导航和会话恢复。但默认系统提示会把隐藏的工具从列表和规则里剔除——如果编排型工具自己藏起了文件读取器,默认提示里的 skills 提示也不会提到它,所以编排方得自己把使用说明讲出来。
codemode 只用了这个钩子加上 exposure、reachesTools 和 ctx.executeTool()。这意味着你可以用不同的名字实现完全相同的行为,这也是它被标记为 replaceable 的原因之一(见本章 10.8 节)。
executeTool:工具调用工具
ctx.executeTool(name, args, options) 让一个工具跑另一个工具。这条路径和模型发起的调用走同一套流程:参数校验、tool_call 和 tool_result 处理器、权限检查,全都过一遍。所以你没法通过它绕开你的权限扩展。
const outcome = await ctx.executeTool("read", { path: "README.md" }, {
signal,
onUpdate,
});嵌套调用的行为细节值得记住,因为它们会影响你写渲染器的方式:
ctx.tools 列出 executeTool() 能调的工具——注意这是按 callable 集合过滤的,model-only 的工具不在其中。
返回值:抛异常还是标记错误
这两者语义完全不同:
throw→ 产生一个失败的工具结果。给「这个工具运行失败了」。- 返回
isError: true→ 模型看到一个错误,但结构化数据还在。
docs/extensions.md:147 给的场景是:你要报一个「失败了但带着数据」的结果。
To report a failure that still carries data, return the result with
isError: trueinstead of throwing: the model sees an error, and scripts still receivestructuredContent.
同样的逻辑适用于 structuredContent。声明了 outputSchema 的工具,codemode 脚本拿到的是 structuredContent 而不是文本;没声明的工具,脚本拿到文本。而 tool_result 处理器如果改写了 content 却不同时返回 structuredContent,结构化内容就丢了——ToolResultEventResult 的注释里专门写了这条(types.ts:1481)。
并发与文件写入
同一条 assistant 消息里的工具调用可以并行。但:
- 共享可变内存状态的工具,要显式设
executionMode: "sequential"; - 改动文件的工具,要把完整的读-改-写包在
withFileMutationQueue()里。
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
await withFileMutationQueue(absolutePath, async () => {
const current = await readFile(absolutePath, "utf-8");
await writeFile(absolutePath, transform(current));
});这个队列(src/core/tools/file-mutation-queue.ts:32)按文件路径串行化操作,不同文件仍然并行。key 是 realpath 解析后的路径——文件还不存在时退回 resolve() 的结果。所以两个不同路径指向同一个文件时(软链接)也会被正确串行。
内置的 write 和 edit 都是这么做的。你自定义的写文件工具也应该。
自定义渲染
renderCall 和 renderResult 决定你的工具在交互式 transcript 和 HTML 导出里长什么样。返回值是 pi-tui 的 Component。
不提供它们就用内置渲染器。tool-override.ts 覆盖 read 时特意没写渲染函数,注释说明是为了自动继承语法高亮、行号、截断警告。
如果只改某个工具的渲染方式(不改行为),用 registerToolRenderer:
pi.registerToolRenderer((toolName, next) => {
if (toolName !== "my_tool") return undefined;
return { renderCall: myRenderCall }; // next() ?? mine 只补空位
});这个 resolver 对还没注册的工具也生效——比如恢复的会话里 MCP 服务器还没连上,但 transcript 里已经有它的调用记录了。next() 返回后续 resolver(按扩展加载顺序)和工具自身会用的渲染器,所以 next() ?? mine 是「只填空位」的标准写法。
10.4 注册命令、快捷键与渲染器
命令
pi.registerCommand("tools", {
description: "Enable/disable tools",
handler: async (_args, ctx) => {
if (ctx.mode !== "tui") {
ctx.ui.notify("/tools requires TUI mode", "error");
return;
}
// ...
},
});handler 拿到的是 ExtensionCommandContext,它在 ExtensionContext 之上多了一批只有命令里能安全调用的操作:
为什么这些是命令专属的——源码注释说得很直接(types.ts:400):
Includes session control methods only safe in user-initiated commands.
因为从生命周期事件里调它们可能让运行时死锁。这些操作会替换或等待运行时,而运行时正在等你这个 handler 返回。
newSession / fork / switchSession 都有一个 withSession 回调,签名是 (ctx: ReplacedSessionContext) => Promise<void>。这个回调拿到的是绑定到新会话的全新 context:
await ctx.fork(entryId, {
withSession: async (newCtx) => {
// newCtx 是新会话的上下文,会话级操作都该在这里做
},
});会话替换会让旧 context 失效。切换之前只抓纯数据,别把 ctx 相关的引用带过去。
快捷键
pi.registerShortcut("ctrl+g", {
description: "Toggle something",
handler: async (ctx) => { /* ... */ },
});KeyId 就是 pi-tui 的按键标识(packages/tui/src/keys.ts),也就是 "ctrl+g"、"alt+enter"、"pageUp" 这类字符串。
有两条限制值得知道。第一,注册会被已有保留键位屏蔽的快捷键,注释说明这是为了让迭代顺序不影响结果——不管扩展先注册还是后注册,被应用级保留的键位都拿不到。第二,冲突会被检测并上报,keybindings.ts 里有 KeybindingConflict 类型。
CLI flag
pi.registerFlag("my-flag", {
description: "Enable my thing",
type: "boolean",
default: false,
});
// 在别处读
const on = pi.getFlag("my-flag");type 只有 "boolean" 和 "string" 两种。扩展注册的 flag 会出现在 pi --help 的帮助文本里,位置在「Extension CLI Flags」段(src/cli/args.ts:273)——所以 description 是要给人读的,不是注释。
四种渲染器注册
前两个是配对的:你写入什么类型的条目,就注册什么类型的渲染器,否则它在 transcript 里不可见。返回 undefined 表示「我不管,交给下一个」。
Markdown transformer 拿到的是 (markdown, context),context 里有 messageType("user" / "assistant" / "assistant-thinking")、isStreaming 和 availableWidth。流式状态下换渲染策略要小心——你已经画出去的字符不会消失。
供应商、MCP server 与虚拟模型
这三个注册面比前面几个都「重」——它们改的不是界面,是 agent 往哪个模型发请求、能用什么工具。
registerProvider(name, config) 有四种互不排斥的用法(src/core/extensions/types.ts:1806 的注释列得很全):
四种可以组合。最小的代理配置长这样:
pi.registerProvider("my-proxy", {
baseUrl: "https://proxy.example.com",
apiKey: "$PROXY_API_KEY",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude 4 Sonnet (proxy)",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 16384,
},
],
});apiKey 支持三种写法:字面量、$ENV_VAR / ${ENV_VAR} 环境变量插值、或者以 ! 开头表示后面跟一条取 key 的命令。
时序上有一个例外值得记住:在初次扩展加载期间调用 registerProvider() 会被排队,等 runner 绑定 context 之后再应用。之后再调用就立即生效——所以从命令处理器或事件回调里改供应商不需要 /reload。unregisterProvider(name) 会移除该供应商下所有模型,并恢复被它覆盖掉的内置模型。
custom-provider-anthropic/ 这个示例把四种用法都用上了:自定义 API 标识、自定义 streamSimple、OAuth(/login 用)、环境变量取 key。
registerMcpServer(name, config) / unregisterMcpServer(name) 的 config 形状与 mcp.json 里的一模一样,加上一组扩展专用字段:exposure、toolExposure、description、enabled、timeout。
pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
pi.unregisterMcpServer("jira");有五条规则值得逐条记住,因为每条都有对应的「我做错了什么」:
- 加载期注册的服务器在
session_start时连接,和mcp.json的服务器一起;之后注册的立即连接。 unregisterMcpServer()关闭连接并让该服务器的工具不可达。- 注册不持久化,每次加载都要重新注册。
mcp.json里的同名服务器优先级更高,/mcp里能看到覆盖情况。- 同名重复注册会替换你自己的上一次注册;但别的扩展注册过的名字、非法名字、非法 config 都会抛异常。
registerVirtualModel(model) / unregisterVirtualModel(provider, id) 是一层不同的抽象:它注册的不是物理模型,而是一个可选项,它把每次请求路由到一个物理模型。
选中的记的是虚拟模型名(ctx.model、model_change 条目),assistant 消息里记的是路由器实际选的物理模型和思考级别。这个不对称是有意的——它让「用户选了 X」和「实际跑了 Y」这两件事都可追溯。provider 可以是任意 provider id,也可以带 models 列表一次注册多个虚拟模型。详细的路由契约在 docs/virtual-models.md,这里不展开。
ctx.ui:能不写组件就别写组件
ExtensionUIContext(src/core/extensions/types.ts:149)提供了一整套现成的东西:
规则很简单:只有当交互需要自己的渲染和输入时才用 ctx.ui.custom()。选择、确认、输入、多行编辑这四件事都有现成实现,重写一遍只会得到一个更差的版本。
ctx.ui.custom() 的 factory 收四个参数:(tui, theme, keybindings, done),返回一个组件或一个 Promise。调用 done() 结束这次交互,Promise 随之 resolve,组件被 dispose。别用 OverlayHandle.hide() 去关一个通过 custom() 创建的 overlay——官方文档专门写了这条(docs/tui.md:74)。
还有一条身份约束:一个组件实例属于一次交互。第二次开始同一个交互就新建一个实例,不要复用上一个。
10.5 41 个生命周期事件
先说怎么数对
ExtensionAPI.on() 的全部重载声明在 src/core/extensions/types.ts:1597 到 1664 之间,一行一个。这里有个坑:
只 grep on(event: 会漏掉 9 个事件,因为有 9 个重载因为参数太长被换行写成了多行式:
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): () => void; // 单行式
on( // 多行式
event: "resources_discover",
handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>,
): () => void;正确的数法是两种都抓,32 + 9 = 41。这个数字也是 01 项目概览 里那份清单里的一个。
# 单行式
grep -c 'on(event: "' packages/coding-agent/src/core/extensions/types.ts
# 多行式
grep -cPzo 'on\(\s*\n\s*event: "' packages/coding-agent/src/core/extensions/types.ts | tr -cd '\0' | wc -c按职责分成五组
组一:启动与资源(3 个)
组二:会话生命周期(10 个)
注意 session_before_* 家族:4 个 before 加 4 个 after,全部是成对的。before 可以取消操作、可以替换产出,after 只能看。
组三:模型请求(7 个)
context 和 context_with_system 的区别是这一组最容易搞错的:
context拿到的是去掉 prompt 和工具系统消息的对话消息。Pi 在 handler 返回后自己恢复那部分,所以 handler 弄不丢它,也不用维护它。context_with_system拿到的是完整 transcript,返回什么就用什么。用它就得自己在 index 0 放一条 system 消息。
provider_stream_event 有三条硬约束(docs/extensions.md:110):
event.data是Pi 能拿到的最早的结构化值,不一定是原始 HTTP 字节或 SSE 帧;- 当作只读——改它会影响后续的归一化过程;
- 只通知,不持久化。
handler 按流顺序被 await,所以慢 handler 会拖慢流消费。handler 出错会被上报,但不影响供应商响应。官方给了个 opt-in 的查看器 debug-provider.ts,按 assistant 消息分组显示原始事件。
cache_warming_decision 能覆盖空闲时的 prompt cache 刷新决策,返回 { action: "warm" } 或 { action: "stop" }。最后一个返回 action 的 handler 赢。
组四:消息流与 Agent 运行(9 个)
agent_start 到 agent_end 之间包含压缩检查和 before_agent_start,Pi 在这段时间里就算忙——这期间发进来的消息会进到正在启动的这次 run 里。自动重试、恢复、压缩或排队任务可能在 agent_end 之后继续。
message_update 是逐 token 的。message_end 可以替换已定稿的消息,但替换必须保持原来的 role(MessageEndEventResult 的注释,types.ts:1492)。
组五:工具执行、UI 与输入(12 个)
五组加起来是 3 + 10 + 7 + 9 + 12 = 41。这不是巧合,也不是我为了凑数分的组——分组的依据是「这个事件的返回值有没有意义」,下一小节就讲这个。
agent_before_settle 和 agent_settled 这一对需要单独说,放在 10.5 末尾。
顺序、并发与退订
三条机制性规则:
一,handler 按扩展的加载顺序和注册顺序执行。 这是 10.2 里「项目扩展排在全局扩展前面」的真正意义——同一个事件上,项目扩展的 handler 先跑。
二,pi.on() 返回一个退订函数。 而且「改动不影响已经在进行中的派发」。派发开始时会拍一个 handler 快照。
三,handler 会被 await。 慢 handler 真的会让整条流水线等。所以 handler 里不要做长任务——要用 ctx.executeTool() 的后台任务,或者干脆把工作挪到下一个事件。
三类事件,三种语义
不是所有事件的返回值都有效。这一点文档写得很清楚:"Use each event's declared result type rather than assuming every return value has an effect."
写扩展最常见的 bug 就是「在纯通知事件里 return 了东西,期待它生效」。类型系统会拦一部分(ExtensionHandler<E> 的 R 默认是 undefined),但对返回了 void 的 handler 就拦不住了。
tool_call 与 tool_result
tool_call 的返回值 ToolCallEventResult(types.ts:1454)只有三个字段:
改参数不是靠返回值,是靠原地改 event.input。 这是最容易搞错的一点:
// ToolCallEventResult.block 的注释:
// Block tool execution. To modify arguments, mutate `event.input` in place instead.terminate 有个容易被忽略的条件(types.ts:1460):
Early termination only happens when every finalized tool result in the batch sets this to true.
必须是这一批里每一个工具都同意终止,agent 才会真的停。 一个工具说「够了」,另一个还在正常返回,就不算数。
tool_result 的处理器是组合式的:每个 handler 都能看到前面 handler 的修改。所以注册顺序在这里有实际语义。
user_bash 的传播规则(docs/extensions.md:128):
- 返回
undefined→ 传给下一个 handler;如果没人处理,本地执行; - 返回
operations或result→ 停止传播,扩展接管了; - handler 抛异常 → 命令被阻断,不会掉回本地执行。
最后这条又是 fail-safe,和 tool_call 一致。
边界事件:agent_before_settle 与 agent_settled
这两个事件是整个 41 个里语义最特殊的,值得单独讲。
文档的原话(docs/extensions.md:65):
agent_before_settleis the final actionable boundary: it can append entries and request one continuation.agent_settledis final and notification-only; use it when an integration needs to know Pi will not continue automatically.
区别在于**「Pi 还会不会自己继续」**:
agent_before_settle触发时,Pi 还在决定要不要再来一次模型请求。你的 handler 可以追加条目(custom、custom_message、context_edit、compaction四种草稿之一),也可以返回continue: true要求再跑一次模型请求。agent_settled触发时,自动重试、恢复、压缩、排队任务都已经不会发生了。它只通知。
turn_end 也是可操作的边界,机制相同。两者的事件类型都是 BoundaryState(types.ts:1015),handler 返回 BoundaryResult:
export interface BoundaryResult {
entries?: SessionBoundaryDraft[];
continue?: boolean;
}事件对象上还挂了一个 context 预览:
export interface BoundaryContextPreview {
contextEntries: ProjectedSessionEntry[];
contextMessages: AgentMessage[];
llmMessages: Message[];
pendingMessages: AgentMessage[];
canContinue: boolean;
}canContinue 告诉你这次还能不能再来一回——用它在续跑条件不满足时自动停下。
一个必须自己加的护栏:无条件返回 continue: true 会死循环。 文档专门警告了这点。正确写法是判断条件:
pi.on("agent_before_settle", async (event, ctx) => {
// 只在明确缺了东西的时候才续跑
if (event.outcome !== "completed") return undefined;
if (/* 检查是否真的需要再来一轮 */) {
return { continue: true };
}
return undefined;
});AgentActivityOutcome 有三个取值:"completed"、"aborted"、"error"。被用户中止的 run 不该被自动续上。
before_agent_start 的两种消息类型
这个事件区分两类 run,靠 messageType:
systemPrompt 字段是只读的当前系统提示,systemPromptOptions 是可变的 prompt 段落——后面的 handler 能看到前面 handler 的修改。
返回 message 会往这两种 run 里各加一条;想跳过自定义消息启动的 run,就检查 messageType。
但这里有一条更重要的建议(docs/extensions.md:104):
Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning
systemPrompt, or settingforceSystemPrompt, replaces the whole prompt for that run while the transcript continues recording the structured sections.
改 prompt 段落,而不是返回整段 systemPrompt。 区别在于 transcript 里记的是什么:改段落时 transcript 记结构化的差异,返回整段时 transcript 仍然记结构化段落但模型实际收到的是你替换的文本——两者会不一致。
input:三种动作
InputEventResult(types.ts:1184)是一个三选一的判别联合:
事件带 source("interactive" / "rpc" / "extension")和 streamingBehavior("steer" / "followUp",空闲时为 undefined)。两个队列的语义在 03 交互式会话 里展开。
10.6 数据该放在哪一层
扩展一定会存点东西。放错层的代价在会话变长之后才显现出来,所以这一节的原则值得先看。
src/core/extensions/types.ts:229 给了一张四行表,判断依据只有一个问题:这份状态参与对话吗?
appendEntry 与它的渲染器配对
appendEntry<T>(customType, data) 写入一条自定义条目。它不参与 LLM 上下文——这是它和 sendMessage 的根本区别。
因为 transcript 里看不见它,所以你必须注册一个对应的 registerEntryRenderer,否则用户也看不到:
pi.appendEntry<ToolsState>("tools-config", { enabledTools: [...strings] });
pi.registerEntryRenderer<ToolsState>("tools-config", (entry, options, theme) => {
// 返回一个 pi-tui Component
});tools.ts 这个例子把整条链路演示了一遍——它注册了一个 /tools 命令让用户勾选启用哪些工具,把选择写进 tools-config 条目,然后在 session_start 和 session_tree 里读回来。
从分支重建状态
这里有一条容易写错的规则(docs/extensions.md:236):
Reconstruct branch-sensitive state from
ctx.sessionManager.getBranch()duringsession_start. Do not rebuild it from every file entry because abandoned branches represent alternative histories.
用 getBranch(),不要遍历整个会话文件。 会话是一棵树,被放弃的分支代表「另一种可能的历史」——如果从所有条目重建,你在旧分支上做的选择会污染新分支。
tools.ts 里的 restoreFromBranch() 就是标准写法:
function restoreFromBranch(ctx: ExtensionContext) {
allTools = pi.getAllTools();
const branchEntries = ctx.sessionManager.getBranch();
let savedTools: string[] | undefined;
for (const entry of branchEntries) {
if (entry.type === "custom" && entry.customType === "tools-config") {
const data = entry.data as ToolsState | undefined;
if (data?.enabledTools) savedTools = data.enabledTools; // 取最后一个
}
}
if (savedTools) {
const allToolNames = allTools.map((t) => t.name);
// 过滤掉已经不存在的工具
enabledTools = new Set(savedTools.filter((t) => allToolNames.includes(t)));
applyTools();
} else {
enabledTools = new Set(pi.getActiveTools());
}
}注意那个过滤。扩展升级后老会话里记录的某个工具可能已经不存在了,直接 setActiveTools 传进去只会被静默忽略,但你自己那份 enabledTools 集合还会留着它,下次 applyTools() 继续传,没完没了。
扩展之间怎么通信
pi.events 是一个极简事件总线(src/core/event-bus.ts):
export interface EventBus {
emit(channel: string, data: unknown): void;
on(channel: string, handler: (data: unknown) => void): () => void;
}没有类型、没有通配符、没有优先级。event-bus.ts 的示例演示了完整用法:
pi.events.on("my:notification", (data) => {
const { message, from } = data as { message: string; from: string };
currentCtx?.ui.notify(`Event from ${from}: ${message}`, "info");
});
pi.registerCommand("emit", {
description: "Emit my:notification event (usage: /emit message)",
handler: async (args) => {
pi.events.emit("my:notification", { message: args.trim() || "hello", from: "/emit command" });
},
});用 namespace:event 形式的 channel 名避免撞车——这个惯例是 event-bus.ts 自己的例子立下的。
注意 on() 的 handler 是 async 包装的:src/core/event-bus.ts:19 把 handler 包在一个 try/catch 里,异常只 console.error,不影响 emit 的其他订阅者。这跟 pi.on() 的 fail-safe 语义不一样——pi.events 是尽力而为的通知,pi.on() 里 tool_call 的失败是阻断。
还有一点:事件总线是当前运行时内的。/reload 之后总线清空,所有订阅都没了。
10.7 安全:扩展就是进程内代码
事实陈述
官方文档第一段就写着(docs/extensions.md:5):
An extension runs inside the Pi process with the same operating-system permissions. It can inspect prompts, tool calls, files, credentials, and session history, so load extensions only from sources you trust.
展开成一句可操作的话:你的扩展可以读你的 API key、你的 SSH 私钥、你的 .env、你的完整对话历史,只要它想。没有任何沙箱、没有任何权限模型把它和 Pi 本体隔开。
这不是缺陷,这是边界。Pi 拒绝的是「替用户做决定的那部分代码」(见 01 的「原语而非功能」),而扩展系统选择的是完全信任这条路:给它完整的 OS 权限,换取「任何工作流都能实现」。这是一笔明确的交易,不是疏漏。
推论很直接:你装了扩展,就等于装了它。 一个扩展的依赖树、它读的环境变量、它发的网络请求,都和 Pi 本体同级。审扩展要审它的依赖链,不是只看那一个 .ts 文件。
tool_call 抛异常会阻断调用
这是这一节唯一有技术含量的部分,也是最容易被写反的。
emitToolCall()(src/core/extensions/runner.ts:1249)没有 try/catch:
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
const ctx = this.createContext();
let result: ToolCallEventResult | undefined;
for (const { handlers } of snapshotEventHandlers(this.extensions, "tool_call")) {
for (const handler of handlers) {
const handlerResult = await handler(event, ctx); // 没有 try/catch
if (handlerResult) {
result = handlerResult as ToolCallEventResult;
if (result.block) return result; // block 立刻短路
}
}
}
return result;
}异常直接往上抛到 _beforeToolCall()(src/core/agent-session.ts:659):
try {
return await runner.emitToolCall({ ... });
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
}继续 rethrow。 文档的说法是(docs/extensions.md:263):
Pi reports handler errors and continues where possible. A
tool_callhandler failure blocks the tool as a fail-safe.
对比一下同一个文件里的 emitUserBash()(runner.ts:1269),那里是 catch 之后 emitError 再 throw——语义一样:handler 失败 = 操作被阻断。
为什么这是对的。假设反过来:handler 抛异常就放行工具。那你的权限扩展一旦写错一个 typo(比如 ctx.hasUI 拼错成 ctx.hasUi,在 if 里得到 undefined,然后 await undefined.select(...) 抛 TypeError),所有危险工具就全部放行了。而错误处理器恰恰是最容易出错的地方。
对比 tool_result:工具执行失败变成给模型的一个错误结果,而不是让整个 agent 循环崩掉。这两个方向不同,是因为它们的风险方向不同——执行前的决策出错是安全问题,执行后的报告出错只是功能问题。
一个直接推论:你的 tool_call handler 必须自己写 try/catch。
pi.on("tool_call", async (event, ctx) => {
try {
// ... 判断逻辑
} catch (err) {
// 显式失败,而不是让异常冒上去阻断
return { block: true, reason: `policy check failed: ${String(err)}` };
}
});区别在于:block: true 会给模型一个清晰的 reason,让它知道是被策略挡了;异常冒上去则是一个笼统的失败。两者都阻断,但前者可诊断。
没有的东西,和有替代品的东西
Pi 没有的东西(docs/security.md):逐次工具调用审批。官方文档的原话是它 does not ask for approval before every tool call。
它有的是:
前两项在 06 工具与信任边界 里展开,第四项在 16 隔离与约束(官方给了四种形态)。
annotations 值得单独强调一遍,因为它是「让用户自己拼出审批系统」的关键零件。 它是 MCP 协议自带的一等概念,Pi 完整继承了语义。没有它,你要判断「这个工具危险吗」只能靠工具名硬编码;有了它,MCP 服务器可以自己声明,我上一节那段 needsApproval 逻辑就是完整的策略引擎。
pi.events 不是安全边界
既然扩展之间要通信,pi.events 看起来像个内服的 API 网关。它不是。
任何扩展都能 pi.events.emit() 任何 channel。任何扩展都能 pi.events.on() 监听任何 channel 并拿到原始数据。它是一个共享的、无认证的消息总线。
所以:永远不要通过 pi.events 传递凭据或敏感数据。 如果两个扩展之间需要交换数据,用扩展自己的配置或一个它们都能访问的外部存储,并且明确知道你在信任谁。
10.8 内置扩展可以被替换
四个内置扩展,三个可替换
src/extensions/index.ts:7:
export const builtInExtensions: InlineExtension[] = [
{ name: "llama.cpp", factory: llamaExtension, builtin: true },
// Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party
// MCP extension) takes over instead of running alongside the built-in one.
{ name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true },
{ name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },
{ name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },
];codemode、tool-search、mcp 三个带 replaceable: true,llama.cpp 不带。
替换是靠名字冲突自动发生的,不是靠声明。 没有「我要替换 mcp」这种配置项。机制在 omitReplacedExtensions()(src/core/resource-loader.ts:120):
const names = (extension: Extension) => [
...[...extension.tools.keys()].map((name) => `tool:${name}`),
...[...extension.commands.keys()].map((name) => `command:${name}`),
...[...extension.flags.keys()].map((name) => `flag:${name}`),
];
const taken = new Map(
extensions
.filter((extension) => !extension.replaceable)
.flatMap((extension) => names(extension).map((name) => [name, extension] as const)),
);算法拆开看:
- 把每个扩展注册的工具、命令、flag 名字加上前缀,变成
tool:read、command:mcp、flag:xxx这样的统一命名空间; - 所有不可替换的扩展占据这些名字,形成一张占用表;
- 每个
replaceable扩展检查自己的名字里有没有被占用的;有就被整个丢掉,没有就正常加载。
所以「写一个也注册 codemode 工具的扩展」就足以顶掉内置的 codemode 扩展——不需要任何特殊标记。你甚至可以只用 pi.registerCommand("mcp", …) 顶掉内置 MCP 扩展的 /mcp 命令。
被顶掉时会给出一条警告(resource-loader.ts:146),原文很长但值得看,它告诉你怎么恢复:
Extension ... registers
command/mcp, so built-in extensionmcpwas not loaded. To usemcp, runpi configand make sure it is enabled under Built-in extensions, then disable or remove the existing extension. We recommend only having one or the other loaded at a time.
注意 pi config 本身就是一个交互式 TUI,它没有「子命令列表」这种形态——想看内置扩展开关,打开它,在 Built-in extensions 那一栏里改。
官方文档还给了一张「需求 → API」的对照表(docs/extensions.md:72),写扩展之前扫一眼很有用:
对照表里没列但同样存在的两个:unregisterMcpServer() / unregisterProvider() / unregisterVirtualModel() 用来撤销注册,pi.getMcpServers() 用来读回注册结果(给自建 MCP 客户端用,详见 10.8 节)。
替换 MCP 扩展意味着接管的职责
顶掉内置 MCP 扩展之后,那个扩展就得自己负责连接服务器。Pi 给的接口是两个:
// 加载期注册的,在 session_start 读
pi.on("session_start", async () => {
const servers = pi.getMcpServers();
// 自己起连接
});
// 加载之后注册/注销的
pi.on("mcp_servers_change", async (event) => {
// event.servers 是变化后的完整列表
});文档说明得很清楚(docs/extensions.md:206):
When nothing does, because another extension replaced it, each registration is reported as an extension error.
如果没人接,registerMcpServer() 的每一次调用都会报成扩展错误。 这是一个自检机制:你顶掉了内置扩展却没实现连接逻辑,Pi 会告诉你,不会静默失败。
如果你没替换内置扩展,只是想读 MCP 服务器列表来做点别的(比如在状态栏显示连接了几个),那么你也可以自己去连——内置扩展和你的扩展可以同时连同一批服务器,这没问题。
什么时候不该替换
三个判断标准:
一,你想改的只是行为,不是协议。 那就别写新扩展,用 tool_call / tool_result 事件包装。tool-override.ts 覆盖 read 工具的路径就是这个思路——它连内置扩展都不需要碰,直接注册同名工具。
二,你只是想改显示。 那用 registerToolRenderer,它对未注册的工具也生效,比替换整个扩展轻得多。
三,你不确定内置扩展未来会变什么。 内置扩展的行为跟着 Pi 版本走。你基于某个版本写了一堆假设(服务器连接的时机、exposure 的默认值、缓存策略),下一次升级就可能不成立。而扩展 API 本身有版本承诺,被替换掉的那部分没有。
10.9 事实来源
本文所有数字都可以在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。
下一篇 11 Pi 包与分发 讲怎么把这一章的东西包装成别人能 pi install 的东西。想先看看能力边界在哪,回 06 工具与信任边界;想理解事件为什么会这么设计,回 18 pi-agent-core。