跳到主内容
创建时间:最后更新:作者:白川

扩展开发

41 个生命周期事件、工具暴露模型,以及扩展为什么等于进程内代码

前面几篇都在讲 Pi 内置了什么:8 个工具、4 个默认启用、MCP 已经内置、Codemode 是个扩展。这一篇反过来,从零写一个扩展。

写完你会得到三样东西:一个 ~/.pi/agent/extensions/xxx.ts 文件就能跑起来的最小闭环;一张「我想做的事该注册到哪」的对照表;以及一份关于「装扩展」这件事到底意味着什么的清醒认识。

先说结论,它是全文最容易被跳过、也最不该被跳过的一节。

10.1 第一个扩展

一个扩展长什么样

Pi 对扩展的形态要求只有一条:默认导出一个工厂函数,接收 ExtensionAPI。

typescript
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:

typescript
// 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)直接指一个文件:

bash
pi --extension ./hello.ts

配套的开关是 --no-extensions / -ne,关掉扩展发现和内置扩展,但显式的 -e 路径仍然生效。这让你能在排查「是不是某个扩展搞坏了」时把现场隔离干净。

一个真实一点的工具

上面那个例子演示了命令。工具是扩展最常做的事,写法也不复杂:

typescript
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 或定时器。

typescript
export default function (pi: ExtensionAPI) {
  const server = startSomething();   // 不要这样
  pi.on("session_shutdown", () => server.close());
  return server;
}

理由是:有些调用路径会加载扩展但不启动会话。pi 的某些子命令、某些 SDK 用法只做资源加载就跑掉了。你在工厂里开的端口会跟着泄漏出去,而且没人会来关它。

正确的位置是 session_start,或者干脆等到真正需要它的那个命令/工具被调用时:

typescript
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)按固定顺序收集路径,先到先得,同一个路径只取一次:

顺序来源位置
1
项目本地
<cwd>/.pi/extensions/
2
全局
~/.pi/agent/extensions/
3
显式配置
settings.json 的 extensions 键、命令行 --extension

这里的 .pi 就是项目配置目录,CONFIG_DIR_NAME 的值。项目扩展排在全局扩展前面,因为它们的加载顺序决定 handler 的执行顺序(后面会讲为什么这很重要)。项目扩展要走 project trust 判定(见 06 工具与信任边界),全局扩展不需要。

目录里放什么算数

discoverExtensionsInDir()(src/core/extensions/loader.ts:791)只有三条规则,而且只扫一层,不递归:

text
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)实现,判定顺序是:

  1. 目录里有 package.json 且其中 pi.extensions 字段非空 → 按它声明的路径加载;
  2. 否则有 index.ts → 加载它;
  3. 否则有 index.js → 加载它;
  4. 都没有 → 这个目录不是扩展。

「不递归超过一层」是刻意的。复杂的包必须用 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 之后接住这个事件:

typescript
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_trust event that runs before project extensions load.

也就是说,只有全局扩展和命令行显式指定的扩展能在这个时点插手。项目扩展自己还没被加载,它当然也没法决定自己要不要被加载——这个顺序是自洽的,不是 bug。

ProjectTrustEventResult 很简单:{ trusted: "yes" | "no" | "undecided", remember?: boolean }。

三种模式都能加载

扩展在交互式、RPC、JSON、print 四种模式下都会加载。四种模式的差别在 UI:

模式ctx.modectx.hasUIctx.ui 能力
交互式 TUI
"tui"
true
完整终端 UI
RPC
"rpc"
true
对话框和通知可转发,自定义终端组件不行
JSON
"json"
false
无
Print
"print"
false
无

ExtensionMode 这个联合类型定义在 src/core/extensions/types.ts:323。

这带来一条硬性纪律:工具行为和事件处理必须与渲染解耦。一个只在 TUI 下能跑的工具,在 CI 里 --mode json 调用就会炸。判断条件分开写:

typescript
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:

typescript
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。按「给谁看」分组:

字段面向作用
name
模型
工具调用里用的标识
description
模型
工具用途说明
promptSnippet
模型
默认系统提示「可用工具」段里的一行摘要
promptGuidelines
模型
追加到默认系统提示 Guidelines 段的条目
parameters
模型
TypeBox 参数 schema
label
人
UI 标签
details(返回值)
渲染器
结构化渲染与状态重建
exposure
运行时
模型怎么够到这个工具(见下)
namespace
运行时
工具分组,MCP server 那种
annotations
运行时
行为提示,权限扩展据此决定要不要确认
reachesTools
运行时
声明这次调用可能触达哪些其他工具
prepareLoadout
运行时
编排型工具调整模型看到的工具视图
executionMode
运行时
"sequential" 或 "parallel" 覆盖默认执行模式
renderShell
渲染
是否让工具自己画边框
renderCall / renderResult
渲染
自定义绘制
prepareArguments
运行时
schema 校验前的兼容性垫片
outputSchema
运行时
structuredContent 的 JSON Schema

promptSnippet 和 promptGuidelines 经常被漏掉,但它们直接影响模型表现。 默认系统提示里有一段「可用工具」,如果你的工具没有 promptSnippet,它就不会出现在那一段里——模型只能从工具列表的 description 得知它存在。想要更好的效果,就补上。

暴露模型:五个值,一个都不能理解错

这是扩展开发里最容易搞反的一块。ToolExposure 定义在 src/core/extensions/types.ts:516:

typescript
export type ToolExposure = "direct" | "model-only" | "codemode" | "deferred" | "hidden";
值声明给模型ctx.executeTool() 可调用Codemode 列出
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 不会。

typescript
// types.ts:513
// `direct` and `model-only` tools are activated when they are registered; the others are not.

这个行为在写「加载器工具」时是刚需:先把所有工具都注册上,让可选的那些保持未激活,然后用一个 loader 工具调 pi.setActiveTools() 挑出需要的组合。setActiveTools() 的名字必须是已经注册过的,未知名字会被忽略。

第二,工具无法注销。

typescript
// 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 一致:

字段含义
readOnlyHint
不修改环境
destructiveHint
可能删除或覆盖,而不只是追加
idempotentHint
同参数重复调用无额外效果
openWorldHint
触达开放世界(web 等)而非封闭域

这些提示来自工具作者,不被验证。它们的价值是让一个权限扩展能写出一句可读的策略。官方文档给的例子(docs/extensions.md:167):

typescript
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 的分组:

typescript
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) 声明这次调用可能触达哪些工具:

typescript
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) 更进一步,它让工具在激活期间修改模型看到的工具视图:

typescript
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 /tree and resume.

它们仍然被写进 transcript。所以 active 集合能扛过 /tree 导航和会话恢复。但默认系统提示会把隐藏的工具从列表和规则里剔除——如果编排型工具自己藏起了文件读取器,默认提示里的 skills 提示也不会提到它,所以编排方得自己把使用说明讲出来。

codemode 只用了这个钩子加上 exposure、reachesTools 和 ctx.executeTool()。这意味着你可以用不同的名字实现完全相同的行为,这也是它被标记为 replaceable 的原因之一(见本章 10.8 节)。

executeTool:工具调用工具

ctx.executeTool(name, args, options) 让一个工具跑另一个工具。这条路径和模型发起的调用走同一套流程:参数校验、tool_call 和 tool_result 处理器、权限检查,全都过一遍。所以你没法通过它绕开你的权限扩展。

typescript
const outcome = await ctx.executeTool("read", { path: "README.md" }, {
  signal,
  onUpdate,
});

嵌套调用的行为细节值得记住,因为它们会影响你写渲染器的方式:

事实后果
调用 id 是 <父 id>/<n>,由 pi 分配
不能自己拼 id
嵌套调用不进 transcript
结果只回到调用方,由它自己报告
会发 tool_execution_start / _update / _end,都带 parentToolCallId
你的 UI 要能处理嵌套来源
每次调用记录一份有界的 nestedCalls(名字、参数、状态、耗时、错误,不含结果)
压缩时的文件列表和 HTML 导出用得上
单次调用参数超 8 KiB 或单个工具结果超 32 KiB 时参数被省略
长参数在记录里是空的
最多保留 256 条,丢失任何东西时标 complete: false
不要依赖它做审计
每一层的 usage 会加到调用方工具结果的 usage 上
你的工具只报自己的用量
executeTool 对工具失败永不 reject,未知工具、校验失败、被阻断、抛异常都变成 isError: true
必须检查 isError,别假设它成功

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: true instead of throwing: the model sees an error, and scripts still receive structuredContent.

同样的逻辑适用于 structuredContent。声明了 outputSchema 的工具,codemode 脚本拿到的是 structuredContent 而不是文本;没声明的工具,脚本拿到文本。而 tool_result 处理器如果改写了 content 却不同时返回 structuredContent,结构化内容就丢了——ToolResultEventResult 的注释里专门写了这条(types.ts:1481)。

并发与文件写入

同一条 assistant 消息里的工具调用可以并行。但:

  • 共享可变内存状态的工具,要显式设 executionMode: "sequential";
  • 改动文件的工具,要把完整的读-改-写包在 withFileMutationQueue() 里。
typescript
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:

typescript
pi.registerToolRenderer((toolName, next) => {
  if (toolName !== "my_tool") return undefined;
  return { renderCall: myRenderCall };   // next() ?? mine 只补空位
});

这个 resolver 对还没注册的工具也生效——比如恢复的会话里 MCP 服务器还没连上,但 transcript 里已经有它的调用记录了。next() 返回后续 resolver(按扩展加载顺序)和工具自身会用的渲染器,所以 next() ?? mine 是「只填空位」的标准写法。

10.4 注册命令、快捷键与渲染器

命令

typescript
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 之上多了一批只有命令里能安全调用的操作:

方法作用
waitForIdle()
等 agent 流式输出结束
newSession()
开新会话
fork(entryId)
从某个条目分叉出新会话文件
navigateTree(targetId)
在会话树里跳
switchSession(path)
切到别的会话文件
reload()
重载扩展、skill、prompt、theme、上下文文件

为什么这些是命令专属的——源码注释说得很直接(types.ts:400):

Includes session control methods only safe in user-initiated commands.

因为从生命周期事件里调它们可能让运行时死锁。这些操作会替换或等待运行时,而运行时正在等你这个 handler 返回。

newSession / fork / switchSession 都有一个 withSession 回调,签名是 (ctx: ReplacedSessionContext) => Promise<void>。这个回调拿到的是绑定到新会话的全新 context:

typescript
await ctx.fork(entryId, {
  withSession: async (newCtx) => {
    // newCtx 是新会话的上下文,会话级操作都该在这里做
  },
});

会话替换会让旧 context 失效。切换之前只抓纯数据,别把 ctx 相关的引用带过去。

快捷键

typescript
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

typescript
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 是要给人读的,不是注释。

四种渲染器注册

方法渲染什么
registerMessageRenderer(customType, renderer)
通过 sendMessage() 发出的自定义消息
registerEntryRenderer(customType, renderer)
通过 appendEntry() 写入的自定义条目
registerMarkdownTransformer(transformer)
用户和 assistant 的 Markdown 在交互式 transcript 里渲染之前
registerToolRenderer(resolver)
工具调用的绘制(见上一节)

前两个是配对的:你写入什么类型的条目,就注册什么类型的渲染器,否则它在 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 的注释列得很全):

给了什么效果
models
替换这个供应商下所有已有模型
只有 baseUrl
只覆盖 URL,已有模型保留
oauth
为 /login 注册 OAuth 供应商
streamSimple
注册自定义 API 流处理器

四种可以组合。最小的代理配置长这样:

typescript
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。

typescript
pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
pi.unregisterMcpServer("jira");

有五条规则值得逐条记住,因为每条都有对应的「我做错了什么」:

  1. 加载期注册的服务器在 session_start 时连接,和 mcp.json 的服务器一起;之后注册的立即连接。
  2. unregisterMcpServer() 关闭连接并让该服务器的工具不可达。
  3. 注册不持久化,每次加载都要重新注册。
  4. mcp.json 里的同名服务器优先级更高,/mcp 里能看到覆盖情况。
  5. 同名重复注册会替换你自己的上一次注册;但别的扩展注册过的名字、非法名字、非法 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)提供了一整套现成的东西:

方法用途
select / confirm / input
阻塞式对话框
notify(message, type)
非阻塞通知,type 是 info / warning / error
setStatus(key, text)
状态栏文本,传 undefined 清除
setWorkingMessage / setWorkingVisible / setWorkingIndicator
流式输出期间的工作指示器
setWidget(key, content, options)
编辑器上/下方的常驻内容
setHeader / setFooter
换掉内置页眉/页脚,传 undefined 恢复
setTitle(title)
终端窗口/标签标题
setTheme / getTheme / getAllThemes
主题
setToolsExpanded / getToolsExpanded
工具输出展开状态
editor(title, prefill?)
多行编辑对话框
custom(factory, options)
自己画一整块交互界面
setEditorComponent(factory)
换掉主编辑器
addAutocompleteProvider(factory)
在内置补全之上叠一层
onTerminalInput(handler)
原始终端输入监听,返回退订函数

规则很简单:只有当交互需要自己的渲染和输入时才用 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 个重载因为参数太长被换行写成了多行式:

typescript
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): () => void;   // 单行式
on(                                                                                     // 多行式
	event: "resources_discover",
	handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>,
): () => void;

正确的数法是两种都抓,32 + 9 = 41。这个数字也是 01 项目概览 里那份清单里的一个。

bash
# 单行式
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 个)

事件时机能力
project_trust
项目扩展加载前
决定 trusted,可 remember
session_start
会话启动/加载/重载
最常见的挂载点,reason 有 5 种取值
resources_discover
session_start 之后
追加 skill / prompt / theme 路径

组二:会话生命周期(10 个)

事件时机能力
session_info_changed
会话元数据变化
通知
session_before_switch
切换会话前
可 cancel
session_before_fork
分叉前
可 cancel、skipConversationRestore
session_before_compact
压缩前
可 cancel、替换 compaction 结果
session_compact
压缩成功
通知
session_compact_failed
压缩失败/中止
通知
session_shutdown
运行时拆除前
释放资源
mcp_servers_change
扩展注册/注销 MCP server 后
通知,自建 MCP 客户端用
session_before_tree
树导航前
可 cancel、提供摘要
session_tree
树导航后
通知

注意 session_before_* 家族:4 个 before 加 4 个 after,全部是成对的。before 可以取消操作、可以替换产出,after 只能看。

组三:模型请求(7 个)

事件时机能力
context
每次 LLM 调用前
改写对话消息,不含 prompt 和工具系统消息
context_with_system
同上,在所有 context 之后
拿到含系统消息的完整 transcript,handler 拥有它
before_provider_request
请求发出前
可替换整个 payload
before_provider_headers
请求头组装后、HTTP 调用前
原地改写 headers,null 删除某个头
after_provider_response
响应收到后、流消费前
只读 status 和 headers
provider_stream_event
每个解析出的流事件
只读通知,见下
cache_warming_decision
空闲期的 prompt cache 刷新决策
可要求 warm 或 stop

context 和 context_with_system 的区别是这一组最容易搞错的:

  • context 拿到的是去掉 prompt 和工具系统消息的对话消息。Pi 在 handler 返回后自己恢复那部分,所以 handler 弄不丢它,也不用维护它。
  • context_with_system 拿到的是完整 transcript,返回什么就用什么。用它就得自己在 index 0 放一条 system 消息。

provider_stream_event 有三条硬约束(docs/extensions.md:110):

  1. event.data 是Pi 能拿到的最早的结构化值,不一定是原始 HTTP 字节或 SSE 帧;
  2. 当作只读——改它会影响后续的归一化过程;
  3. 只通知,不持久化。

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
一次 agent 循环的开始与结束
turn_start / turn_end
一个 turn(一条 assistant 消息)的开始与结束
message_start / message_update / message_end
单条消息的流式生命周期
agent_before_settle / agent_settled
见本节末尾

agent_start 到 agent_end 之间包含压缩检查和 before_agent_start,Pi 在这段时间里就算忙——这期间发进来的消息会进到正在启动的这次 run 里。自动重试、恢复、压缩或排队任务可能在 agent_end 之后继续。

message_update 是逐 token 的。message_end 可以替换已定稿的消息,但替换必须保持原来的 role(MessageEndEventResult 的注释,types.ts:1492)。

组五:工具执行、UI 与输入(12 个)

事件时机能做什么
tool_execution_start / _update / _end
工具执行生命周期
通知
tool_call
工具执行前
改输入、阻断
tool_result
工具执行后
改 content / details / structuredContent / isError / usage
model_select / thinking_level_select
模型或思考级别变更
通知
user_bash
用户用 ! / !! 执行 shell
接管执行或返回结果
ui_prompt_start / ui_prompt_end
阻塞式 UI 提示前后
通知
input
用户输入到达、agent 处理前
继续 / 改写 / 接管
before_agent_start
一次 run 开始前
改 prompt 段落、注入自定义消息

五组加起来是 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."

类型例子返回值
纯通知
agent_start、message_update、session_compact
返回什么都不影响
转换数据
context、message_end、tool_result、input
返回值替换数据
阻断操作
tool_call、session_before_*、cache_warming_decision
返回值取消或改变操作

写扩展最常见的 bug 就是「在纯通知事件里 return 了东西,期待它生效」。类型系统会拦一部分(ExtensionHandler<E> 的 R 默认是 undefined),但对返回了 void 的 handler 就拦不住了。

tool_call 与 tool_result

tool_call 的返回值 ToolCallEventResult(types.ts:1454)只有三个字段:

字段作用
block
阻断这次工具执行
reason
阻断原因,会进模型看到的错误
terminate
提示 agent 在这一批工具跑完后停止

改参数不是靠返回值,是靠原地改 event.input。 这是最容易搞错的一点:

typescript
// 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_settle is the final actionable boundary: it can append entries and request one continuation. agent_settled is 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:

typescript
export interface BoundaryResult {
	entries?: SessionBoundaryDraft[];
	continue?: boolean;
}

事件对象上还挂了一个 context 预览:

typescript
export interface BoundaryContextPreview {
	contextEntries: ProjectedSessionEntry[];
	contextMessages: AgentMessage[];
	llmMessages: Message[];
	pendingMessages: AgentMessage[];
	canContinue: boolean;
}

canContinue 告诉你这次还能不能再来一回——用它在续跑条件不满足时自动停下。

一个必须自己加的护栏:无条件返回 continue: true 会死循环。 文档专门警告了这点。正确写法是判断条件:

typescript
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:

messageType触发来源携带字段
"user"
提交的 prompt、RPC prompt、pi.sendUserMessage()
prompt 和 images
"custom"
pi.sendMessage(..., { triggerTurn: true })
message,没有 prompt

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 setting forceSystemPrompt, 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)是一个三选一的判别联合:

返回效果
{ action: "continue" }
放行
{ action: "transform"; text; images? }
改写输入
{ action: "handled" }
完全接管

事件带 source("interactive" / "rpc" / "extension")和 streamingBehavior("steer" / "followUp",空闲时为 undefined)。两个队列的语义在 03 交互式会话 里展开。

10.6 数据该放在哪一层

扩展一定会存点东西。放错层的代价在会话变长之后才显现出来,所以这一节的原则值得先看。

src/core/extensions/types.ts:229 给了一张四行表,判断依据只有一个问题:这份状态参与对话吗?

状态类型存储理由
跟随当前分支的工具状态
工具结果的 details
它本来就是这一轮对话的产物
不进模型上下文的持久数据
pi.appendEntry()
写进会话文件,但不参与 LLM 上下文
要存也要发给模型的自定义内容
pi.sendMessage()
既是条目,也是模型能看见的内容
跨会话的数据
外部存储
会话文件活不过一次会话

appendEntry 与它的渲染器配对

appendEntry<T>(customType, data) 写入一条自定义条目。它不参与 LLM 上下文——这是它和 sendMessage 的根本区别。

因为 transcript 里看不见它,所以你必须注册一个对应的 registerEntryRenderer,否则用户也看不到:

typescript
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() during session_start. Do not rebuild it from every file entry because abandoned branches represent alternative histories.

用 getBranch(),不要遍历整个会话文件。 会话是一棵树,被放弃的分支代表「另一种可能的历史」——如果从所有条目重建,你在旧分支上做的选择会污染新分支。

tools.ts 里的 restoreFromBranch() 就是标准写法:

typescript
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):

typescript
export interface EventBus {
	emit(channel: string, data: unknown): void;
	on(channel: string, handler: (data: unknown) => void): () => void;
}

没有类型、没有通配符、没有优先级。event-bus.ts 的示例演示了完整用法:

typescript
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:

typescript
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):

typescript
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_call handler 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。

typescript
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。

它有的是:

机制覆盖什么不覆盖什么
project trust
启动期项目资源加载
运行期工具调用
tool_call 拦截
你自己写的策略
你没写的部分
annotations 提示
给策略提供分类信息
本身不做任何事
隔离环境
真正兜底的边界
需要你自己搭

前两项在 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:

typescript
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):

typescript
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)),
);

算法拆开看:

  1. 把每个扩展注册的工具、命令、flag 名字加上前缀,变成 tool:read、command:mcp、flag:xxx 这样的统一命名空间;
  2. 所有不可替换的扩展占据这些名字,形成一张占用表;
  3. 每个 replaceable 扩展检查自己的名字里有没有被占用的;有就被整个丢掉,没有就正常加载。

所以「写一个也注册 codemode 工具的扩展」就足以顶掉内置的 codemode 扩展——不需要任何特殊标记。你甚至可以只用 pi.registerCommand("mcp", …) 顶掉内置 MCP 扩展的 /mcp 命令。

被顶掉时会给出一条警告(resource-loader.ts:146),原文很长但值得看,它告诉你怎么恢复:

Extension ... registers command /mcp, so built-in extension mcp was not loaded. To use mcp, run pi config and 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),写扩展之前扫一眼很有用:

能力主要 API
观察或修改生命周期行为
pi.on()
增加一个模型可调用的操作
pi.registerTool()
增加一个 / 命令
pi.registerCommand()
增加快捷键或 CLI flag
pi.registerShortcut() / pi.registerFlag()
发送用户消息或自定义消息
pi.sendUserMessage() / pi.sendMessage()
持久化不进上下文的会话数据
pi.appendEntry()
改活动工具、模型或思考级别
pi 上的会话控制方法
增加模型供应商
pi.registerProvider()
增加 MCP server
pi.registerMcpServer()
为每次请求选模型
pi.registerVirtualModel()
增加终端渲染
渲染器注册与 ctx.ui
与另一个扩展通信
pi.events

对照表里没列但同样存在的两个:unregisterMcpServer() / unregisterProvider() / unregisterVirtualModel() 用来撤销注册,pi.getMcpServers() 用来读回注册结果(给自建 MCP 客户端用,详见 10.8 节)。

替换 MCP 扩展意味着接管的职责

顶掉内置 MCP 扩展之后,那个扩展就得自己负责连接服务器。Pi 给的接口是两个:

typescript
// 加载期注册的,在 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 事实来源

本文所有数字都可以在仓库里复算:

断言来源
扩展运行在 Pi 进程内,拥有同等 OS 权限
packages/coding-agent/docs/extensions.md:5
用 jiti 加载,无需构建步骤
docs/extensions.md:38;src/core/extensions/jiti-loader.ts
41 个生命周期事件 = 单行式 32 + 多行式 9
src/core/extensions/types.ts:1597-1664 全部 on(event:) 重载
只 grep on(event: 会漏 9 个
同上,多行式重载共 9 个
5 种 exposure:direct / model-only / codemode / deferred / hidden
src/core/extensions/types.ts:516
注册 direct / model-only 才激活
src/core/extensions/types.ts:513
工具无法注销,只能重注册成 hidden
src/core/extensions/types.ts:511;docs/extensions.md:161
setActiveTools 忽略未知与 hidden 工具
src/core/extensions/types.ts:1775
4 个 annotations:readOnlyHint / destructiveHint / idempotentHint / openWorldHint
src/core/extensions/types.ts:522-531
缺失的 hint 按 MCP 默认值算(非只读、可能破坏、开放世界)
docs/extensions.md:167
嵌套调用 id 为 <父 id>/<n>;参数超 8 KiB、单结果超 32 KiB 省略、最多 256 条
docs/extensions.md:149
嵌套调用 usage 累加到调用方;executeTool 对失败永不 reject
src/core/extensions/types.ts:386-393
4 种模式加载,ExtensionMode = tui/rpc/json/print
src/core/extensions/types.ts:323;docs/extensions.md:251
扩展加载顺序:项目 → 全局 → 显式路径
src/core/extensions/loader.ts:828-876
目录发现 3 条规则,不递归超过一层
src/core/extensions/loader.ts:791;resolveExtensionEntries 同文件 :749
--extension / -e,--no-extensions / -ne
src/cli/args.ts:186-189;帮助文本 :326-327
异步工厂会被 await 完再继续启动
docs/extensions.md:56
重载后不得复用旧运行时状态
docs/extensions.md:50
只有个人扩展和命令行扩展能参与 project_trust
docs/extensions.md:50
tool_call 处理器失败按 fail-safe 阻断
docs/extensions.md:263;src/core/extensions/runner.ts:1249、src/core/agent-session.ts:659-682
tool_call 改参数要原地改 event.input
src/core/extensions/types.ts:1455
terminate 需整批工具全部同意才生效
src/core/extensions/types.ts:1460
user_bash 返回 undefined 才落到本地执行;handler 失败阻断命令
docs/extensions.md:128
agent_before_settle 可行动,agent_settled 只通知
docs/extensions.md:65-68;src/core/extensions/types.ts:1022-1037
BoundaryResult 只含 entries 与 continue
src/core/extensions/types.ts:1022
无条件 continue: true 会循环
docs/extensions.md:118
context 不含系统消息,context_with_system 含
src/core/extensions/types.ts:880-898;docs/extensions.md:116
provider_stream_event 只读、只通知、不持久化
docs/extensions.md:110
cache_warming_decision 最后一个返回 action 的 handler 赢
docs/extensions.md:122
状态分层四行表
src/core/extensions/types.ts:229-239;docs/extensions.md:227-234
分支敏感状态用 getBranch() 重建
docs/extensions.md:236
withFileMutationQueue 按 realpath 串行化同一文件的写操作
src/core/tools/file-mutation-queue.ts:32
pi.events 的 handler 异常被吞(不影响其他订阅者)
src/core/event-bus.ts:19-27
4 个内置扩展,后三个 replaceable: true
src/extensions/index.ts:7-13
替换靠工具/命令/flag 名字冲突
src/core/resource-loader.ts:120-151
无扩展接管 MCP 连接时注册会报扩展错误
docs/extensions.md:206
pi 在 run 启动(含压缩检查与 before_agent_start)期间即算忙
docs/extensions.md:62-64
registerProvider 的四种用法:models / baseUrl / oauth / streamSimple
src/core/extensions/types.ts:1806-1859
加载期 registerProvider 被排队,之后调用立即生效(不需 /reload)
src/core/extensions/types.ts:1814-1817
apiKey 支持字面量、$ENV_VAR / ${ENV_VAR} 插值、! 前缀命令
src/core/extensions/types.ts:1936
registerMcpServer 的 config 与 mcp.json 同形,另加 exposure / toolExposure / description / enabled / timeout
docs/extensions.md:197
MCP 注册不持久化;mcp.json 同名优先;跨扩展同名注册抛异常
docs/extensions.md:204
虚拟模型:选择记虚拟名,assistant 消息记实际物理模型与思考级别
src/core/extensions/types.ts:1902-1911
扩展注册的 flag 出现在 pi --help 的 Extension CLI Flags 段
src/cli/args.ts:273-279
「需求 → API」对照表
docs/extensions.md:72-87

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。

下一篇 11 Pi 包与分发 讲怎么把这一章的东西包装成别人能 pi install 的东西。想先看看能力边界在哪,回 06 工具与信任边界;想理解事件为什么会这么设计,回 18 pi-agent-core。