Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Pi/第11章

第11章:扩展系统 —— 不改源码给 Agent 加能力

25 分钟 · 更新于 2026-09-01

第11章:扩展系统 —— 不改源码给 Agent 加能力

补全章节说明:原教程(dg-ai-notes)插图版只覆盖到第 10 章。本章依据原作者仓库中留存的第 11 章设计文档(_chapter-design/第11章-扩展系统-设计文档.md),结合 Pi v0.81.1 官方源码(packages/coding-agent/src/core/extensions/)与官方文档(docs/extensions.md)补全,行文风格与粒度对齐前 10 章。源码行号以 v0.81.1 为准,与前 10 章引用的 v0.80.2 可能有少量偏移。

前 10 章里,Pi 的核心机制都讲完了:Agent Loop、模型调用、工具系统、消息系统、事件驱动、上下文工程、压缩、会话管理。你可能注意到一个现象——这些核心都很克制(extensions 目录四个核心文件加起来约 3700 行),但 Pi 的实际能力远超核心代码的覆盖范围。

比如:禁止 rm -rf、接入本地 Ollama、给 Agent 加一个查数据库的 SQL 工具、在终端里跑 Doom——这些 Pi 核心代码里统统没有。它们从哪儿来?

答案是扩展系统。这一章我们打开它。


一、六个真实需求,一个共同困境

先看六个真实场景。它们都是 Pi 用户在实际工作里遇到的需求:

  1. 安全红线:团队规定 Agent 不许执行 rm -rfsudo——但 Pi 默认是 YOLO 模式,没有权限弹窗
  2. 私有模型:公司要求只能用内网的 vLLM 网关,模型列表还得动态拉取
  3. 领域工具:你想给 Agent 加一个 query_db 工具,直接查业务数据库
  4. 上下文注入:每次调 LLM 前自动带上当前时间和 git 状态
  5. 工作流命令:输入 /deploy 就执行部署脚本,结果回报给 Agent
  6. 误操作保护/new 清空会话之前,先弹个确认框

第一反应可能是:给 Pi 提 PR?等官方排期,还不一定收。改 Pi 源码?可以,但每次 npm update 都要重新 merge 一遍你的私改——第 7 章开头讲"工具调用日志"时算过这笔账,维护成本会随时间无限累积。

更根本的矛盾是:Pi 不可能预先知道你团队要禁 rm -rf 这些需求高度个性化,任何"官方内置"都覆盖不全。第 1 章讲过 Pi 的减法哲学——不做 MCP、不做子 Agent、不做权限弹窗。Pi 官方 README 的说法是:

"Pi is aggressively extensible so it doesn't have to dictate your workflow." (Pi 激进地可扩展,所以它不必替你决定工作流。)

"激进地可扩展"落到工程上,就是本章的主角。先看它长什么样。

一个扩展 = 一个 TS 文件

把下面这个文件放到 ~/.pi/agent/extensions/permission-gate.ts,重启 Pi(或 /reload),需求 1 就解决了:

typescript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
    const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];

    pi.on("tool_call", async (event, ctx) => {
        if (event.toolName !== "bash") return undefined;

        const command = event.input.command as string;
        if (dangerousPatterns.some((p) => p.test(command))) {
            if (!ctx.hasUI) {
                // 非交互模式:默认拦截
                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;
    });
}

这是官方仓库 examples/extensions/permission-gate.ts 的真实代码(略有精简)。三十行,不碰 Pi 一行源码:

  • 导出一个默认函数(官方叫工厂函数,factory),Pi 加载时调用它,传进来一个 pi 对象
  • pi.on("tool_call", ...) 订阅"工具即将执行"事件
  • 处理器里检查命令,危险就弹窗问用户,被拒绝则返回 { block: true, reason } ——工具就不会执行

Pi 官方对扩展的定义是:"TypeScript modules that extend pi with custom tools, commands, keyboard shortcuts, event handlers, and UI components"(用自定义工具、命令、快捷键、事件处理器和 UI 组件扩展 Pi 的 TypeScript 模块)。上面六个需求全部有对应的官方示例:需求 2 是 registerProvider,需求 3 是 registerTool,需求 4 是 context 事件,需求 5 是 registerCommand,需求 6 是 session_before_switch 事件。

看起来很美好。但如果你停下来想一下,会发现一个不对劲的地方。


二、第一个问题:Pi 不认识你,怎么听你的?

矛盾:Pi 核心里没有"禁止 rm -rf"这段逻辑

permission-gate.ts 是你写的文件,Pi 的源码里没有它的任何信息。Pi 发布的时候,作者 Mario 根本不知道世界上会有你这个扩展、你要拦 rm -rf。那凭什么你返回一个 { block: true },Pi 的工具管线就乖乖停下来?

如果让你来设计,直觉方案可能是"插入代码"——把扩展代码注入到工具执行的路径里。但这意味着 Pi 核心要理解你的代码逻辑,耦合太深,一崩全崩。

Pi 的答案是第 7 章讲过的老朋友:事件。但用法升了一级。

事件总线:Pi 只管广播,扩展自己认领

回忆第 7 章:Agent 内核有 10 种 AgentEvent,通过 subscribe(listener) 分发给 TUI、持久化这些消费者。那是"观测"——听到事件后做自己的事,不影响 Agent。

扩展系统在 coding-agent 层建了另一条更强的事件总线:34 种 ExtensionEvent(定义在 types.ts:1023-1048(repo/packages/coding-agent/src/core/extensions/types.ts)),覆盖 Pi 工作流的八个关键介入点。和第 7 章最大的不同是——很多事件的处理器返回值能改变 Pi 的行为

用广播电台类比:Pi 在八个频道持续发节目("我要执行工具了""我要调 LLM 了""用户要切换会话了"),扩展订阅自己感兴趣的频道。第 7 章的听众只能听;扩展系统的听众可以打电话进直播间点歌——返回一个值,节目内容就变了。

八个介入点,对应事件分组如下:

#介入点代表事件扩展能干什么
1启动与信任project_trustresources_discover接管项目信任决策;追加技能/提示词/主题路径
2会话生命周期session_startsession_before_switch/fork/compact/treesession_shutdown 等 9 种否决会话切换/分叉;自定义压缩摘要;清理资源
3用户输入inputuser_bash改写输入、直接接管处理
4Agent 启动前before_agent_start注入消息、改系统提示词
5LLM 调用前后contextbefore_provider_headers/requestafter_provider_response修改消息列表、改请求头、替换 payload
6工具执行tool_calltool_resulttool_execution_start/update/end拦截工具、改参数、改结果
7消息流message_start/update/end观测流式输出;替换最终消息
8模型与思考等级model_selectthinking_level_select感知切换,联动 UI/初始化

完整的 34 种事件清单和返回值协议见文末附表。原教程设计文档写的是"28 个事件"(v0.80.2),到 v0.81.1 已经涨到 34 个——扩展介入点本身也在持续生长。

上表是分类视角。官方文档(docs/extensions.md)还给了一张更形象的时间轴视角图——把所有事件按发生顺序钉在一次完整交互的生命周期上,哪个事件能拦、能改,直接标在括号里:

text
pi starts
  │
  ├─► project_trust (user/global and CLI extensions only)
  ├─► session_start { reason: "startup" }
  └─► resources_discover { reason: "startup" }
      │
      ▼
user sends prompt ─────────────────────────────────────────┐
  │                                                        │
  ├─► (extension commands checked first, bypass if found)  │
  ├─► input (can intercept, transform, or handle)          │
  ├─► (skill/template expansion if not handled)            │
  ├─► before_agent_start (can inject message, modify system prompt)
  ├─► agent_start                                          │
  ├─► message_start / message_update / message_end         │
  │                                                        │
  │   ┌─── turn (repeats while LLM calls tools) ───┐       │
  │   │                                            │       │
  │   ├─► turn_start                               │       │
  │   ├─► context (can modify messages)            │       │
  │   ├─► before_provider_headers (can mutate headers)     │
  │   ├─► before_provider_request (can inspect or replace payload)
  │   ├─► after_provider_response (status + headers)       │
  │   │                                            │       │
  │   │   LLM responds, may call tools:            │       │
  │   │     ├─► tool_execution_start               │       │
  │   │     ├─► tool_call (can block)              │       │
  │   │     ├─► tool_execution_update              │       │
  │   │     ├─► tool_result (can modify)           │       │
  │   │     └─► tool_execution_end                 │       │
  │   │                                            │       │
  │   └─► turn_end                                 │       │
  │                                                        │
  ├─► agent_end                                            │
  └─► agent_settled (no retry/compaction/follow-up left)   │
                                                           │
user sends another prompt ◄────────────────────────────────┘

/new (new session) or /resume (switch session)
  ├─► session_before_switch (can cancel)
  ├─► session_shutdown
  ├─► session_start { reason: "new" | "resume" }
  └─► resources_discover

/fork or /clone
  ├─► session_before_fork (can cancel)
  ├─► session_shutdown
  └─► session_start { reason: "fork" }

/compact or auto-compaction
  ├─► session_before_compact (can cancel or customize)
  └─► session_compact

/tree navigation
  ├─► session_before_tree (can cancel or customize)
  └─► session_tree

/model or Ctrl+P
  ├─► thinking_level_select (if thinking level changes)
  └─► model_select

exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
  └─► session_shutdown

配图说明(摘自官方 docs/extensions.md「Lifecycle Overview」,略有精简):主干是"用户发一句话"的完整旅程——输入处理 → Agent 启动 → 若干个 turn(每个 turn 内含 LLM 调用前后和工具执行的全部介入点)→ 收尾;下方几段是斜杠命令触发的会话级事件。注意 tool_call 夹在 tool_execution_start 和真正执行之间、context 在每个 turn 的 LLM 调用前都会重新触发——这两个位置感,光看分类表是看不出来的。

关键洞察是:Pi 不需要预先知道你的需求。它只承诺"在这 34 个时机喊一嗓子,并遵守返回值协议"。 你的 { block: true } 之所以有效,不是因为 Pi 理解了"禁止递归删除",而是因为 tool_call 事件的协议里写着"处理器返回 block=true,则跳过工具执行"。

这是一种用"约定"换"灵活"的设计——约定是事件名 + 返回值协议,灵活是任何人不改源码就能改变 Pi 的行为。

四种事件模式:扩展介入的四个力度

34 种事件不是铁板一块。按"返回值能干什么"分,恰好四种模式,力度从弱到强:

模式 1:纯通知(约 20 种) —— 返回值被忽略,只能观测。如 agent_startturn_endmessage_updatetool_execution_*model_select。用途:状态栏、日志、打点。

模式 2:一票否决 —— 任何一个处理器投反对票,动作立即取消,后面的处理器都不再执行。如 tool_call 返回 { block: true }session_before_switch/fork/compact/tree 返回 { cancel: true }project_trust 返回 { trusted: "no" }

看 runner.ts 里 tool_call 的分发实现(runner.ts:915-936(repo/packages/coding-agent/src/core/extensions/runner.ts)):

typescript
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
    const ctx = this.createContext();
    let result: ToolCallEventResult | undefined;
    for (const ext of this.extensions) {
        const handlers = ext.handlers.get("tool_call");
        if (!handlers || handlers.length === 0) continue;
        for (const handler of handlers) {
            const handlerResult = await handler(event, ctx);
            if (handlerResult) {
                result = handlerResult as ToolCallEventResult;
                if (result.block) {
                    return result;   // ← block=true 立即短路,后续处理器不再执行
                }
            }
        }
    }
    return result;
}

双重 for 循环,顺序 = 扩展加载顺序 × 同一扩展内注册顺序。谁先返回 block: true,循环立即终止——一票否决。

模式 3:链式修改 —— 处理器像流水线工人,前一个的输出是后一个的输入。如 tool_result(改工具结果)、context(改消息列表)、message_end(换最终消息)、before_agent_start(改系统提示词)。

tool_result 的链式实现(runner.ts:860-913(repo/packages/coding-agent/src/core/extensions/runner.ts)):每个处理器收到的是上一个处理器修改后的最新事件,返回的 content/details/isError/usage 字段逐一覆盖,省略的字段保持不变。官方文档明确写着 "tool_result handlers chain like middleware"。所以"扩展 A 截断 8000 字符输出到 500 字符 → 扩展 B 给截断后的结果补充元数据"这种协作是天然支持的。

模式 4:短路接管 —— 处理器说"这事我办了,别人(包括 Pi 自己)都不用管"。如 input 返回 { action: "handled" }(跳过 LLM,扩展自己响应),user_bash 返回 { result }(直接给出命令结果,Pi 不再真的执行)。

四种模式对应四种插件系统的经典需求:观测、否决、加工、代理。设计自己的插件系统时,这个分类几乎可以照搬。

完整闭环:一条 rm -rf 的死亡之旅

把机制串起来,追踪开头那个场景从触发到结束的全链路:

text
1. LLM 输出 toolCall: bash { command: "rm -rf /tmp/build" }
   │
2. Agent Loop 进入工具执行的五步管道(第 5 章)
   │  第 3 步 beforeToolCall 钩子被触发
   │
3. AgentSession 把钩子接到扩展总线(agent-session.ts:474)
   │  agent.beforeToolCall → runner.emitToolCall(event)
   │
4. permission-gate 的处理器被调用
   │  正则命中 → ctx.ui.select() 弹出选择框 → 用户选 "No"
   │  返回 { block: true, reason: "Blocked by user" }
   │
5. emitToolCall 短路返回,Agent Loop 跳过 tool.execute
   │
6. 管道把 block 编码成一条 isError: true 的 ToolResultMessage
   │  内容是 reason:"Blocked by user"
   │
7. 下一轮 LLM 看到这条错误消息,知道命令被拦截
   └─ 它可能换一种方式(如逐个删除文件),或者向用户解释

注意第 6 步——这正是第 5 章"错误即消息"原则的复用:拦截不是异常,而是一条普通的工具结果消息。LLM 被告知"为什么被拦",它可以自我修正。整条链路上没有任何一层需要"理解"扩展的逻辑,各层只遵守自己那段协议。

第 5 章讲五步管道时说"第 3 步 beforeToolCall 可以阻止执行",第 7 章说"这个机制由扩展系统实现"——现在你看到了完整的实现。

再走两条链路:修改型事件怎么"接力"

一票否决讲完了,链式修改也各走一条完整链路。

链路二:给每次 LLM 调用注入当前时间(需求 4)。介入点是 context 事件——Agent Loop 每次调 LLM 前触发,扩展可以修改即将发出的消息列表:

typescript
pi.on("context", async (event, ctx) => {
    // event.messages 是深拷贝,随便改,不会污染会话历史
    return {
        messages: [
            ...event.messages,
            {
                role: "user" as const,
                content: [{ type: "text" as const, text: `[当前时间] ${new Date().toISOString()}` }],
                timestamp: Date.now(),
            },
        ],
    };
});

数据的前后变化:

text
emitContext 之前(会话里的真实消息):        emitContext 之后(发给 LLM 的消息):
[0] user:      "帮我看看这个 bug"            [0] user:      "帮我看看这个 bug"
[1] assistant: "我先读一下代码" + toolCall    [1] assistant: "我先读一下代码" + toolCall
[2] toolResult: "...文件内容..."             [2] toolResult: "...文件内容..."
                                            [3] user:      "[当前时间] 2026-07-24T..."  ← 注入

两个实现细节决定了这条链路的安全性(runner.ts:967-997(repo/packages/coding-agent/src/core/extensions/runner.ts)):

  1. structuredClone(messages) —— 扩展改的是深拷贝。注入的时间消息只活在"这一次 LLM 调用"里,不会写进会话历史。下一轮重新走 context 事件,重新注入最新时间——这恰好是你想要的语义(时间永远是新的),也是第 6 章 transformContext"非破坏性转换"承诺的兑现。
  2. 链式传递 —— 多个扩展都订阅 context 时,后一个拿到的是前一个改完的 currentMessages。注入时间的扩展和注入 git 状态的扩展可以互不知情地叠加。

链路三:工具结果的流水线加工。介入点是 tool_result 事件。假设装了两个扩展——A 负责截断超长输出,B 负责给结果附加元数据:

text
工具原始输出:     content: 8000 字符的编译日志
    │
    ▼ 扩展 A(截断器): 返回 { content: [前 500 字符 + "...(truncated)"] }
当前结果:         content: 500 字符
    │
    ▼ 扩展 B(标注器): 看到的已经是截断后的 500 字符
    │             返回 { details: { originalLength: 8000, truncatedBy: "ext-A" } }
最终结果:         content: 500 字符, details: { originalLength: 8000, ... }
    │
    ▼ Agent Loop 把最终结果包成 ToolResultMessage,进入会话

emitToolResult 的实现(runner.ts:860-913(repo/packages/coding-agent/src/core/extensions/runner.ts))支持部分补丁:处理器返回的 content/details/isError/usage 四个字段里,给了哪个就覆盖哪个,省略的保持现值。B 只关心 details,就不用碰 A 已经处理好的 content——像流水线上第二个工人不必重做第一个工人的活。

这两条链路加上 rm -rf 拦截,你已经见过四种事件模式里的三种在真实数据上怎么运转。第四种(短路接管)的代表 input 事件套路相同:处理器返回 { action: "handled" },输入就不再进入 Agent——官方示例 input-transform.ts 用它实现了"输入 ping 直接回 pong,不惊动 LLM"。


三、第二个问题:工厂函数执行时,Agent 还不存在

扩展怎么写,看起来很简单

再看一遍扩展的骨架:

typescript
export default function (pi: ExtensionAPI) {
    pi.on("session_start", async (_event, ctx) => { ... });
    pi.registerTool({ ... });
    pi.registerCommand("deploy", { ... });
}

导出一个工厂函数,在里面注册。就这?

就这。但有一个致命的时机问题藏在里面。

矛盾:注册时机 vs 运行时机

工厂函数是在 Pi 启动加载阶段执行的——此时 Pi 正在扫描扩展目录、逐个加载文件。那一刻:Agent 还没创建,SessionManager 还没就绪,Agent Loop 更没启动。

pi 对象上除了 on/registerTool 这些注册方法,还有一批动作方法pi.sendMessage()(发消息进会话)、pi.setModel()(切模型)、pi.appendEntry()(写会话记录)……

问题来了:如果你在工厂函数里直接调 pi.sendMessage("扩展已加载!"),会发生什么?消息发给谁?会话还不存在!

三个候选方案:

  • 方案 A:让工厂函数晚点执行,等 Agent 就绪再加载扩展 → 不行,resources_discoverproject_trust 这些事件发生在启动早期,扩展必须早于 Agent 加载
  • 方案 B:给扩展两个对象,注册用 piRegister,动作用 piRuntime,后者稍后再发 → API 割裂,扩展作者要理解两套对象的生命周期
  • 方案 C:一个 pi 对象走天下,但让它"分阶段变身"

Pi 选了 C。这就是扩展系统里最精妙的设计——两阶段绑定

阶段一:throwing stubs——先发工牌,门禁还没开通

Pi 加载扩展前,先创建一个共享的 runtime 对象,所有动作方法都指向一个"抛错桩"(throwing stub)。源码在 loader.ts:170-223(repo/packages/coding-agent/src/core/extensions/loader.ts) 的 createExtensionRuntime

typescript
export function createExtensionRuntime(): ExtensionRuntime {
    const notInitialized = () => {
        throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
    };
    const runtime: ExtensionRuntime = {
        sendMessage: notInitialized,
        sendUserMessage: notInitialized,
        appendEntry: notInitialized,
        setSessionName: notInitialized,
        getActiveTools: notInitialized,
        setActiveTools: notInitialized,
        refreshTools: () => {},   // registerTool 在加载期是合法的,refresh 无事可做
        setModel: () => Promise.reject(new Error("Extension runtime not initialized")),
        // 特例:provider 注册不抛错,而是排队,等核心就绪后统一 flush
        registerProvider: (name, config, extensionPath = "<unknown>") => {
            runtime.pendingProviderRegistrations.push({ name, config, extensionPath });
        },
        // ...
    };
    return runtime;
}

然后 createExtensionAPI(loader.ts:230-393)构造传给工厂函数的 pi 对象:注册类方法直接写入扩展自己的 Mapon 往 handlers Map 里 push,registerTool 往 tools Map 里放);动作类方法全部委托给上面那个共享 runtime——此刻就是抛错桩。

用入职类比:扩展是新员工,工厂函数是入职流程,pi 对象是它领到的工牌。工牌当场就能用的部分是"登记信息"(注册);工牌上的门禁卡(动作)现在刷了就报警——因为大楼(Agent)还没盖好。

注意 registerProvider 这个特例:它既不算纯注册(要写入 ModelRegistry,核心才有),又不能抛错(动态拉模型列表的扩展就是要在工厂里注册 provider)。Pi 的处理是排队——先记下来,等核心就绪统一执行。三种策略(直接生效 / 抛错 / 排队)在同一个对象上共存,按每个方法的语义分别选择。

阶段二:bindCore——大楼盖好,门禁统一开通

Agent、SessionManager 全部就绪后,AgentSession 调用 runner.bindCore(...)(runner.ts:311-408(repo/packages/coding-agent/src/core/extensions/runner.ts),调用点在 agent-session.ts:2359):

typescript
bindCore(actions, contextActions, providerActions?): void {
    // 把真实实现覆盖进共享 runtime 对象——所有扩展的 pi 都引用它,立即生效
    this.runtime.sendMessage = actions.sendMessage;
    this.runtime.sendUserMessage = actions.sendUserMessage;
    this.runtime.appendEntry = actions.appendEntry;
    this.runtime.setModel = actions.setModel;
    // ...全部动作方法逐一替换

    // flush 加载期排队的 provider 注册
    for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) {
        try { /* 写入 ModelRegistry */ } catch (err) { this.emitError({ ... }); }
    }
    this.runtime.pendingProviderRegistrations = [];
    // 此后 registerProvider 改为直连 ModelRegistry,立即生效
    this.runtime.registerProvider = (name, config) => { ... };
}

妙处在于:替换的是共享 runtime 对象的属性,而所有扩展手里的 pi 都委托给这个对象。不需要通知任何扩展"你可以干活了",下一次调用自动就是真实实现。

从数据视角看两个阶段前后发生了什么:

text
                     工厂函数执行前          工厂函数执行后         bindCore 之后
Extension 对象:
  handlers Map        (空)                  tool_call → [handler]   (不变)
  tools Map           (空)                  greet → {definition}    (不变)
  commands Map        (空)                  deploy → {handler}      (不变)

共享 runtime:
  sendMessage         () => throw Error     () => throw Error       actions.sendMessage(真实现)
  setModel            () => reject          () => reject            actions.setModel(真实现)
  pendingProvider     []                    [{name:"vllm", ...}]    [](已 flush 进 ModelRegistry)

注册的成果(左三列)在加载期就沉淀进 Extension 对象的几张 Map;动作的能力(右下角)在 bindCore 一瞬间集体上线。

对扩展作者来说,规则简化成一句话:工厂函数里只注册,事件处理器里随便动。在工厂里调动作方法会得到一个明确的报错(而不是静默失败或未定义行为)——这个错误信息本身就是文档。

对比一下没有这套机制的世界:要么动作方法在加载期是 undefined(调用报一个莫名其妙的 TypeError),要么静默丢弃(扩展作者调试半天不知道为什么没效果),要么强行支持(往一个不存在的会话里发消息,状态错乱)。throwing stubs 把"时机错误"变成了"即时、可读的失败"。

和第 7 章的呼应:第 7 章讲过"对内层受信任的监听器让异常直接暴露"。throwing stubs 是同一哲学在另一个维度的应用——错误的调用时机,第一时间用异常暴露出来。


四、第三个问题:扩展是第三方代码,崩了怎么办?

力量越大,风险越大

至此扩展已经很强了:能拦工具、改消息、注册 provider。但扩展是第三方代码——你从 GitHub 上装的一个 Pi Package,里面的事件处理器要是抛异常了呢?要是写了个死循环呢?要是恶意扩展想劫持你的会话呢?

Pi 用两道防线回答:错误隔离 + API 门面分层

防线一:错误隔离——但有一个刻意的例外

看通用 emit() 的实现(runner.ts:784-816(repo/packages/coding-agent/src/core/extensions/runner.ts)):

typescript
for (const handler of handlers) {
    try {
        const handlerResult = await handler(event, ctx);
        // ...处理返回值
    } catch (err) {
        // 错误隔离:单个 handler 抛错,上报错误监听器,循环继续
        this.emitError({
            extensionPath: ext.path,
            event: event.type,
            error: err instanceof Error ? err.message : String(err),
            stack: err instanceof Error ? err.stack : undefined,
        });
    }
}

每个处理器调用都被 try/catch 包裹。扩展崩了,错误被编码成一条错误通知(TUI 里显示出来),下一个处理器照常执行,Agent 不受影响。这正是第 7 章末尾预告的"对外层不受信任的监听器,由框架做隔离"。

但如果你去读 emitToolCall(上面第二节贴过),会发现它没有 try/catch。这是疏忽吗?

不是。是安全语义的刻意选择。调用方 agent-session.ts:480-485 会捕获这个异常并把它转化为"阻断工具执行"。官方文档一句话说明了策略:

"tool_call errors block the tool (fail-safe)."

想想为什么必须这样:假设 permission-gate 扩展自己出了 bug 抛异常,如果按普通错误隔离处理——吞掉异常继续执行——那 rm -rf放行了。一个安全扩展的 bug 导致安全检查失效,这是最坏的结果。所以 tool_call 的错误语义是 fail-closed(出错时关门):守门人晕倒了,门必须保持关闭。

两种错误语义的分工:

事件类型错误语义理由
观测类、修改类事件fail-open:吞错继续一个状态栏扩展的 bug 不该打断编码工作
tool_call 拦截fail-closed:出错即拦截安全检查失效时,宁可误拦不可漏放

防线二:API 门面——三层窗口,最小权限

第二个问题:扩展处理器拿到的 ctx,能不能调 switchSession() 把用户会话切走?能不能调 reload() 把所有扩展重载一遍?

不能。因为 Pi 给扩展的从来不是内部对象,而是三层门面(Facade)——每层只暴露那个场景下安全的方法(定义在 types.ts:305-378, 1172-1407(repo/packages/coding-agent/src/core/extensions/types.ts)):

text
ExtensionAPI(工厂函数的 pi)
│  注册面:on / registerTool / registerCommand / registerShortcut /
│         registerFlag / registerProvider / registerMessageRenderer ...
│  动作面:sendMessage / appendEntry / setModel / setActiveTools / exec ...
│  ✗ 没有任何会话生命周期控制
│
ExtensionContext(事件处理器和工具执行的 ctx)
│  观测面:cwd / mode / hasUI / model / signal / getSystemPrompt /
│         getContextUsage / sessionManager(只读!ReadonlySessionManager)
│  温和动作:ui.confirm/select/notify / compact() / abort() / shutdown()
│  ✗ 没有 newSession / fork / switchSession / reload
│
ExtensionCommandContext(仅命令处理器的 ctx,extends ExtensionContext)
   特权动作:newSession() / fork() / switchSession() / navigateTree() /
            waitForIdle() / reload() / getSystemPromptOptions()

分层的依据不是"信任等级",而是调用时机的安全性

  • 事件处理器可能在 Agent Loop 的任意时刻被触发——turn 中间、流式输出中间、工具执行中间。这时候切换会话,等于在高速行驶中换轮胎,必然状态错乱(官方文档的原话是"这些方法从事件处理器里调用可能死锁")
  • 命令处理器只在用户显式输入 /命令 时执行——那时 Agent 通常空闲,且用户明确知道自己在触发什么。危险操作放这里,时机可控

还有一个容易忽略的细节:ctx.sessionManager 的类型是 ReadonlySessionManager——事件处理器可以全部会话状态(做决策要用),但入口只有受控的 pi.appendEntry() 等少数方法。观测自由,修改收口。

用开头的需求 6(清空会话前确认)看这套门面在实战里够不够用。官方示例 confirm-destructive.ts

typescript
pi.on("session_before_switch", async (event, ctx) => {
    if (!ctx.hasUI) return;                       // print/json 模式没法弹窗,直接放行

    if (event.reason === "new") {                 // 用户按了 /new
        const confirmed = await ctx.ui.confirm(
            "Clear session?",
            "This will delete all messages in the current session.",
        );
        if (!confirmed) {
            ctx.ui.notify("Clear cancelled", "info");
            return { cancel: true };              // ← 一票否决,会话保持原样
        }
    }
});

注意这个处理器用到的每一样东西都在 ExtensionContext 的能力面内:hasUI 判断运行模式、ui.confirm 弹对话框、sessionManager 读会话状态(完整示例里还检查了"有没有未保存的工作")。它不需要拿不到 switchSession——否决一个切换和发起一个切换,是两种完全不同量级的权力。门面分层的边界画在这里刚刚好。

第三道保险是 stale 失效/reload 或会话切换后,旧的 runner 被 invalidate() 标记(runner.ts:539-546),扩展如果捕获了旧的 ctx 还想用,会得到明确报错("stale after session replacement or reload")而不是静默操作一个已死的会话。ctx 的每个属性都是 getter,读取时都先 assertActive()

一个诚实的边界:这套防线针对的是 bug,不是恶意。扩展是跑在你进程里的任意 TypeScript 代码,能 import fs 就能删你文件——门面拦不住绕过门面的人。官方文档开头就警告:"Extensions run with your full system permissions... Only install from sources you trust."(扩展拥有你的完整系统权限,只安装你信任的来源。)真正的安全边界是容器——这和第 1 章讲的 YOLO 哲学一脉相承。


五、事件之外的另一半:注册型能力

到目前为止讲的都是"事件"——扩展被动等 Pi 喊话。但回看第一节的六个需求,需求 3(SQL 工具)和需求 5(/deploy 命令)不是"拦截 Pi 做的事",而是"给 Pi 加它原本没有的东西"。这就是扩展系统的另一半:注册型能力

ExtensionAPI 上的注册方法一共八个:registerTool(LLM 可调用的工具)、registerCommand(斜杠命令)、registerShortcut(快捷键)、registerFlag(CLI 参数)、registerProvider(模型供应商)、registerMessageRenderer / registerEntryRenderer(自定义渲染)、外加 on(事件订阅本身也是注册)。挑最重要的两个走一遍。

registerTool:给 LLM 加一双新手

需求 3 的实现骨架:

typescript
import { Type } from "typebox";

pi.registerTool({
    name: "query_db",
    label: "Query DB",
    description: "Run a read-only SQL query against the business database",
    parameters: Type.Object({
        sql: Type.String({ description: "SELECT statement to execute" }),
    }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
        const rows = await runQuery(params.sql, { signal });   // signal 支持 Esc 中断
        return {
            content: [{ type: "text", text: formatAsTable(rows) }],
            details: { rowCount: rows.length },
        };
    },
});

几个值得注意的点,全部呼应前面的章节:

  • 参数用 TypeBox schema 声明——第 5 章五步管道的第 2 步(Schema 验证)对扩展工具同样生效。LLM 传错参数,管道会把验证错误编码成消息还给它,你的 execute 根本不会被脏数据调到
  • content 给模型看,details 给功能层看——第 6 章"两个读者"的设计在工具结果上的直接体现。details 还有一个隐藏用途:状态重建。官方推荐把工具的内部状态存进 detailssession_start 时从会话分支里回放恢复——这样第 10 章的树形回退对有状态工具也天然正确(回退后重放的是那条分支上的 details)
  • 注册不限于加载期——registerToolsession_start、命令处理器里调用同样有效,新工具立即可被 LLM 调用。官方示例 dynamic-tools.ts 演示了"运行中动态上线工具"

还有一个彩蛋级用法:注册一个与内置工具同名的工具,就能整体替换内置实现(官方示例 tool-override.ts 覆盖了 read 工具)。扩展系统没有为"覆盖内置"设计任何专门机制——同名即覆盖,注册表就是这么朴素。

registerCommand:/deploy 的完整链路

需求 5,用户输入 /deploy 就执行部署并把结果告诉 Agent:

typescript
pi.registerCommand("deploy", {
    description: "Deploy current branch to staging",
    handler: async (args, ctx) => {              // ctx 是 ExtensionCommandContext!
        await ctx.waitForIdle();                 // 等 Agent 完全空闲(含重试/压缩收尾)

        const result = await pi.exec("./scripts/deploy.sh", [args || "staging"]);

        // 把部署结果作为用户消息交给 Agent,让它继续跟进
        pi.sendUserMessage(
            `部署完成,输出如下,请检查有没有异常:\n${result.stdout}`,
            { deliverAs: "followUp" },
        );
    },
});

链路全景:

text
用户输入 "/deploy prod"
    │
    ▼ 输入路由:扩展命令优先级最高(比 input 事件、技能展开都早)
    │  命中 "deploy",args = "prod"
    ▼ handler 拿到 ExtensionCommandContext
    │  waitForIdle() —— 特权方法,只有命令上下文才有(第四节讲的门面分层)
    │  pi.exec() 跑部署脚本
    ▼ sendUserMessage(..., { deliverAs: "followUp" })
    │  结果进入第 3 章讲的 followUp 队列
    ▼ Agent 被唤醒,读到部署输出,开始分析日志里的警告

看最后一步——命令的终点不是"显示结果",而是"把结果变成 Agent 的输入"。这是 Pi 扩展和传统编辑器插件最不一样的地方:传统插件的输出面向人,Pi 扩展的输出可以面向模型,让确定性的脚本世界和推理的模型世界互相接力。第 3 章的 steering/followUp 队列、第 6 章的自定义消息,都是这场接力的基础设施。

三种杠杆怎么选

加上第 8 章讲过的 Skills,Pi 其实给了三种"加能力"的杠杆,各有最佳射程:

杠杆本质适合不适合
Skill一份 Markdown 说明书,LLM 按需读教模型"怎么做某类事"(流程、规范、工具用法)需要拦截/改变 Pi 行为
扩展-注册型TS 代码,加工具/命令/供应商给模型新能力、给用户新入口纯知识性内容(杀鸡用牛刀)
扩展-事件型TS 代码,挂事件总线改变 Pi 既有行为(拦截、注入、改写)独立的新功能

经验法则:能用 Markdown 说清楚的用 Skill,要执行代码的用注册型,要改变 Pi 自身行为的才用事件型。 三者可以打包进同一个 Pi Package 分发——这就是第 1 章说的"从用 Pi 到改造 Pi 的平滑升级路径"。


六、一个扩展从写完到生效的完整旅程

机制都讲完了,最后把视角拉回文件系统:你写好的 .ts 文件,Pi 是怎么找到、加载、接线的?

第 1 步:发现——三个约定位置

discoverAndLoadExtensions(loader.ts:673-721(repo/packages/coding-agent/src/core/extensions/loader.ts))按序扫描三处:

位置作用域信任要求
~/.pi/agent/extensions/全局(所有项目)
.pi/extensions/(项目内)项目本地项目被信任后才加载
settings.json 的 packages/extensions 配置显式指定(npm/git 包)包安装即视为信任

目录里的规则(单层,不递归):*.ts/*.js 直接加载;子目录则按优先级找入口——package.json 的 pi.extensions 字段 > index.ts > index.jsresolveExtensionEntries,loader.ts:594-624)。这让扩展可以从单文件平滑长大成带 npm 依赖的完整包,目录里放个 package.json + node_modules 就行。

第 2 步:加载——jiti 让 TS 免编译

扩展是 TypeScript,Node 不能直接跑。Pi 用 jiti(运行时 TS 转译器)加载(loader.ts:403-428):

typescript
const jiti = createJiti(import.meta.url, {
    moduleCache: false,
    ...(isBunBinary
        ? { virtualModules: VIRTUAL_MODULES, tryNative: false }  // Bun 单体二进制模式
        : { alias: getAliases() }),                              // npm/源码模式
});
const module = await jiti.import(extensionPath, { default: true });

记忆点就一个:改了扩展文件,/reload 一下就生效,不需要任何 build 步骤。 两种模式是部署形态的适配:Pi 以单体二进制分发时,typebox@earendil-works/pi-* 这些包已打进二进制,virtualModules 把扩展的 import 映射到内置模块;以 npm 安装时,alias 把它们指向真实安装路径。扩展作者对此完全无感。

第 3 步:注册——工厂函数执行

await factory(api)(loader.ts:454-480)。工厂可以是 async 的——Pi 会等它完成再继续启动,所以"先 fetch 内网网关的模型列表再 registerProvider"这种异步初始化是一等公民。执行完毕后,这个扩展的 handlers/tools/commands 几张 Map 就填好了。

第 4 步:接线——bindCore + 工具包装

Agent 就绪后 bindCore 开通动作方法(第三节讲过)。扩展注册的工具还要过一道 wrapRegisteredTool(wrapper.ts:17-37(repo/packages/coding-agent/src/core/extensions/wrapper.ts),全文件仅 45 行)——把 ToolDefinition 适配成 AgentTool 塞进第 5 章讲的五步管道,从此扩展工具和内置工具走完全相同的验证、执行、错误处理路径。tool_call 拦截对扩展工具同样生效——连你自己注册的工具也会被你自己的 permission-gate 检查。

第 5 步:热重载——/reload 的完整循环

/reload 时(agent-session.ts:2603-2626):session_shutdown 事件(给扩展清理资源的机会)→ 清扩展模块缓存 → new 一个全新的 ExtensionRunner 重新走发现/加载/绑定 → 对新实例发 session_start(reason: "reload")。旧 runner 整体标记 stale。

这个能力配合"扩展就是 cwd 下的 TS 文件",产生了官方文档开头那句话描述的玩法:

"pi can create extensions. Ask it to build one for your use case." (pi 会写扩展。让它给你的场景造一个。)

让 Agent 给自己写扩展:你对 Pi 说"帮我写个扩展,每次 turn 结束自动 git commit",它用 write 工具生成 .pi/extensions/auto-commit.ts,你 /reload,能力立即上线。Agent 修改自己的能力闭环,就是靠"目录约定 + jiti 免编译 + 热重载"三件套撑起来的。


七、总结:三大设计与可迁移的方法论

回顾本章的三个问题与三个答案:

1. Pi 不认识你的需求,怎么扩展?——事件总线。 Pi 在工作流的 8 个介入点 emit 34 种事件,用"事件名 + 返回值协议"这个约定,把改变行为的决定权交给扩展。四种事件模式(通知/否决/链式/接管)覆盖了插件介入的全部力度谱系。

2. 工厂函数早于运行时执行,动作方法怎么办?——两阶段绑定。 注册期动作方法是 throwing stubs(调用即明确报错),核心就绪后 bindCore 把真实实现写进共享 runtime 对象,全体扩展无感切换。特殊需求(registerProvider)用排队策略兜底。

3. 第三方代码崩了/越权了怎么办?——错误隔离 + 门面分层。 观测修改类事件 fail-open(吞错继续),安全拦截类事件 fail-closed(出错即拦);三层 API 门面按"调用时机的安全性"分配能力,危险操作只进命令上下文;stale 失效防僵尸引用。

四条可以带走的插件系统设计方法论:

方法Pi 的实现你的项目里怎么用
事件总线暴露关键点8 个介入点 emit 统一事件别为每个定制点写专门 hook 函数;识别工作流关键节点,emit 带协议的事件,扩展性数量级更好
throwing stubs + 延迟绑定注册期抛错、bindCore 替换插件加载早于核心就绪时,用抛错桩把"时机错误"变成即时可读的失败,用共享对象替换实现无感切换
按时机分配门面ExtensionAPI / Context / CommandContext给插件的不是权限分级,而是"什么时机能安全做什么"的分级;读自由、写收口
按后果选择错误语义观测 fail-open,拦截 fail-closed错误隔离不是无脑 try/catch;先问"这个处理器失效的最坏后果是什么",再决定吞错还是阻断

最后值得体会的是第 1 章那张"减法哲学"表格和本章的关系:Pi 敢于不做 MCP、不做子 Agent、不做权限弹窗、不做计划模式,前提是扩展系统足够强——官方 README 列出的扩展能力清单里,"Sub-agents and plan mode"、"Permission gates"、"MCP server integration" 赫然在列,甚至还有 "Make pi look like Claude Code"。减法哲学不是少做功能,而是把功能的决定权移交给用户。扩展系统就是那个移交仪式。


八、下一站

扩展系统让 Pi 有了"不改源码加能力"的机制。但一个新问题随之而来——怎么测试这些能力?

你写了个扩展,注册了 tool_call 处理器返回 { block: true },怎么验证它真的拦住了?更进一步,Pi 自己的 Agent Loop、工具管道、压缩算法,测试要调真的 LLM 吗?LLM 输出不确定、调用要花钱、还依赖网络——传统"给定输入断言输出"的测试方法,在 Agent 系统面前处处碰壁。

Pi 的仓库里有 300+ 个测试文件在回答这个问题。下一章测试模式,我们看它怎么把一个"不确定的系统"测出确定性。


附表:34 种扩展事件速查(v0.81.1)

分组事件模式返回值协议
启动/资源project_trust否决{trusted: "yes"/"no"/"undecided", remember?},首个 yes/no 者胜
启动/资源resources_discover链式(聚合){skillPaths?, promptPaths?, themePaths?}
会话session_start / session_info_changed / session_compact / session_shutdown / session_tree通知
会话session_before_switch / session_before_fork否决{cancel?: true}
会话session_before_compact否决/替换{cancel?}{compaction}(自定义摘要)
会话session_before_tree否决/替换{cancel?}{summary}
Agentagent_start / agent_end / agent_settled / turn_start / turn_end通知
Agentbefore_agent_start链式{message?, systemPrompt?}
Agentcontext链式{messages?}(深拷贝,安全修改)
Providerbefore_provider_headers就地修改mutate event.headersnull 删除
Providerbefore_provider_request链式返回值替换 payload
Providerafter_provider_response通知
消息message_start / message_update通知
消息message_end链式{message?}(role 必须一致)
工具tool_execution_start/update/end通知
工具tool_call否决{block?, reason?}event.input 可就地改参
工具tool_result链式{content?, details?, isError?, usage?} 部分补丁
模型model_select / thinking_level_select通知
输入input链式/接管{action: "continue"/"transform"/"handled"}
输入user_bash接管{operations?}{result}

本章关键源码索引(Pi v0.81.1):

  • packages/coding-agent/src/core/extensions/types.ts:1023-1048 — 34 种 ExtensionEvent 联合类型;:1177-1218on() 的 30 个重载(事件↔返回值对照表);:305-378 — ExtensionContext / ExtensionCommandContext 门面定义
  • packages/coding-agent/src/core/extensions/runner.ts:784-816 — 通用 emit()(错误隔离);:915-936emitToolCall(一票否决,fail-closed);:860-913emitToolResult(链式修改);:311-408bindCore:539-546 — stale 失效
  • packages/coding-agent/src/core/extensions/loader.ts:170-223createExtensionRuntime(throwing stubs);:230-393createExtensionAPI:594-624 — 目录扩展入口解析;:403-428 — jiti 双模式加载;:673-721 — 三路径发现
  • packages/coding-agent/src/core/extensions/wrapper.ts — 扩展工具包装(全 45 行)
  • packages/coding-agent/src/core/agent-session.ts:466-516 — beforeToolCall/afterToolCall 与扩展总线的接线;:2603-2626/reload 流程
  • examples/extensions/permission-gate.tsconfirm-destructive.ts — 本章两个真实示例的完整源码

版本说明:本章基于 Pi v0.81.1 源码撰写(前 10 章基于 v0.80.2)。扩展事件数量随版本演进(v0.80.2 约 28 种 → v0.81.1 为 34 种),机制设计保持稳定。

本章目录
一、六个真实需求,一个共同困境二、第一个问题:Pi 不认识你,怎么听你的?三、第二个问题:工厂函数执行时,Agent 还不存在四、第三个问题:扩展是第三方代码,崩了怎么办?五、事件之外的另一半:注册型能力六、一个扩展从写完到生效的完整旅程七、总结:三大设计与可迁移的方法论八、下一站附表:34 种扩展事件速查(v0.81.1)
苏ICP备2025204887号-2