π Pi Agent 源码学习课程 阶段四 · 11/14
阶段四 · 编码代理 · PI-CODING-AGENT

第 11 讲扩展系统:自定义工具、事件拦截与 UI 集成

Extension 是 Pi 最强的定制机制:一个默认导出的工厂函数,拿到 ExtensionAPI 就能订阅全部生命周期事件、给 LLM 注册新工具、拦截 tool_call、绘制 TUI 组件并把状态写进会话日志。

4~5 小时预计学习时长
docs + 4 源文件extensions.md · loader.ts · runner.ts · types.ts
40+ 事件8 大类,一条生命周期总图串起
Pi 进程 AgentSession + TUI 事件总线 on() 工具目录 registerTool 渲染器 registerRenderer 会话日志 appendEntry permission-gate 拦截 tool_call my-tool.ts LLM 可调用 snake.ts ui.custom 全屏组件 默认导出工厂 (pi: ExtensionAPI) => void 进程内运行 · 与 Pi 同权限

§11.0学习目标

掌握扩展的加载模型:默认导出工厂、进程内全权限的信任前提、~/.pi/agent/extensions/ 与 .pi/extensions/ 的发现规则、-e 临时加载与 /reload 热重载。

背下 ExtensionAPI 的核心方法地图(on / registerTool / registerCommand / appendEntry / setModel …)并理解 8 大类事件与处理器的六种组合语义。

精读 tool_call 拦截的 block 语义与源码消费点,能写出权限门(rm -rf 确认、.env 路径保护)这类真实可用的扩展。

会用 ctx.ui 的完整交互面板(notify / confirm / select / input / setStatus / setWidget / custom),用 appendEntry 做跨会话状态,并理解 print / JSON / RPC 模式下的降级。

§11.1扩展模型:一个默认导出的工厂函数

先用一句话类比:Extension 就是一个被 Pi 在启动时调用一次的 TypeScript 插件模块——它默认导出一个工厂函数,Pi 把 ExtensionAPI 实例作为参数递进来,你在函数体里「订阅事件、注册工具、注册命令」,函数返回后插件挂载完毕。没有清单文件、没有注解扫描、没有单独进程,一个 .ts 文件就是全部。

官方文档 packages/coding-agent/docs/extensions.md(本讲最重要的单篇文档,全文 2500 余行)开篇的 Quick Start 浓缩了全部四种能力:

packages/coding-agent/docs/extensions.md(Quick Start 节选)节选
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  // 1. 订阅事件
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });

  // 2. 拦截 tool_call,可以 block
  pi.on("tool_call", async (event, ctx) => {
    if (isToolCallEventType("bash", event) && event.input.command.includes("rm -rf")) {
      const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
      if (!ok) return { block: true, reason: "Blocked by user" };
    }
  });

  // 3. 注册 LLM 可调用的自定义工具
  pi.registerTool({
    name: "greet", label: "Greet", description: "Greet someone by name",
    parameters: Type.Object({ name: Type.String({ description: "Name to greet" }) }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {} };
    },
  });

  // 4. 注册斜杠命令
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (args, ctx) => { ctx.ui.notify(`Hello ${args || "world"}!`, "info"); },
  });
}

三个必须刻进肌肉记忆的事实:

  • 加载器是 jiti。文档写明「Extensions are loaded via jiti, so TypeScript works without compilation」——loader.ts 用 createJiti 动态 import 扩展文件并取 default 导出,直接写 TS 即可,无需预编译。若模块没有导出函数,加载结果记录错误:Extension does not export a valid factory function。
  • 扩展与 Pi 共享同一套类与注册表。virtual-modules.ts 把 @earendil-works/pi-coding-agent、pi-ai、pi-tui、typebox 等包名映射到 Pi 自带的副本(旧包名 @mariozechner/*、@sinclair/typebox 也同样映射),文档强调「extensions share Pi's classes and registries」——你做 instanceof 检查时面对的是同一个类,不会出现双份 registry。
  • 扩展运行在 Pi 进程内,拥有启动用户的完整操作系统权限。文档安全提示原话:An extension runs inside the Pi process with the same operating-system permissions. It can inspect prompts, tool calls, files, credentials, and session history. 这不是沙箱、不是降权 worker——「只装可信来源的扩展」是唯一的信任边界。
Java 视角 ①

Extension 最接近「Spring Boot Starter + @EventListener + Filter 链」的合体:pi.on() 是 ApplicationEventPublisher 订阅,tool_call 拦截是 Filter,registerTool 是向 SPI 注册新实现。但关键差别是进程与类加载器:Spring 插件至少还有模块边界,而 Pi 扩展与宿主在同一个 Node 进程、同一套模块注册表里运行,没有任何隔离层——等于把第三方 jar 直接放进你的 classpath 根部并以你的全部权限运行。安全模型的分量完全落在「你是否信任这段代码」上。

§11.2加载位置、发现规则与热重载

扩展的放置位置决定它是否被自动发现、是否需要项目信任、能否热重载。官方对照表:

位置作用域 / 说明
~/.pi/agent/extensions/*.ts 或 *.js全局:对所有项目生效
~/.pi/agent/extensions/*/index.ts 或 index.js全局:子目录形式的多文件扩展
~/.pi/agent/extensions/*/package.json(含 pi.extensions 字段)全局:带清单的扩展包,可声明多个入口
.pi/extensions/…项目本地:同上三种布局;仅在项目通过信任后加载
pi -e ./path.tsCLI 临时加载,官方定位是「quick tests」;不在自动发现位置则不能 /reload
settings.json 的 packages / extensions额外路径:npm:@foo/bar@1.0.0、git:github.com/user/repo@v1 或本地路径

发现规则在 loader.ts 的 discoverExtensionsInDir() 中实现,源码注释概括为三条,且「No recursion beyond one level」——目录只向下探一层,更大的包必须用 package.json 清单声明入口:

packages/coding-agent/src/core/extensions/loader.ts节选
/**
 * Discovery rules:
 * 1. Direct files: `extensions/*.ts` or `*.js` → load
 * 2. Subdirectory with index: `extensions/*/index.ts` or `index.js` → load
 * 3. Subdirectory with package.json: `extensions/*/package.json`
 *    with "pi" field → load what it declares
 *
 * No recursion beyond one level. Complex packages must use package.json manifest.
 */

discoverAndLoadExtensions() 的合并顺序值得记住(它决定了多扩展时事件处理器的执行顺序):① 项目本地 .pi/extensions/ → ② 全局 ~/.pi/agent/extensions/ → ③ settings.json / CLI 显式配置路径,全部按解析后的绝对路径去重后统一交给 loadExtensions()。

npm 依赖解析:在扩展目录(或其父目录)放一个 package.json,执行 npm install 后,node_modules/ 里的 import 会自动被解析;宿主提供的包(pi-coding-agent、pi-ai、typebox 等)不要写进 dependencies。多入口清单长这样:

packages/coding-agent/docs/extensions.md(Extension Styles 示例)节选
{
  "name": "my-extension",
  "dependencies": { "zod": "^3.0.0", "chalk": "^5.0.0" },
  "pi": {
    "extensions": ["./src/index.ts"]
  }
}

热重载:只有放在两个自动发现目录里的扩展能用 /reload 热重载。重载流程是完整的「关停—重建」:先向旧扩展实例派发 session_shutdown(reason: "reload"),重新加载资源,再派发 session_start(reason: "reload")与 resources_discover(reason: "reload")。所以内存态不会自动幸存——清理放 session_shutdown,重建放 session_start,这是所有有状态扩展的固定骨架。

另一个容易忽视的细节:工厂函数可能在从不开启会话的调用中运行(例如 pi --list-models)。文档明确禁止在工厂里启动后台资源(进程、socket、文件监听、定时器),必须延迟到 session_start 或真正用到它的命令/事件里,并注册幂等的 session_shutdown 清理。

§11.3ExtensionAPI 方法总览

ExtensionAPI 定义在 types.ts(约 1600 行处),实现则在 loader.ts 的 createExtensionAPI():注册类方法写入当前扩展对象,动作类方法委托给共享 runtime,runtime 未初始化时调用动作方法会抛错。按用途分组记忆:

分组方法一句话职责
事件订阅on(event, handler)订阅 40+ 生命周期事件;返回退订函数,仅移除该注册
工具registerTool() · getActiveTools() · getAllTools() · setActiveTools(names)注册 LLM 可调用工具;加载期与运行期都可用,动态注册立即生效、无需 /reload
命令 / 键位 / 旗标registerCommand() · registerShortcut() · registerFlag() · getFlag() · getCommands()注册 /cmd 斜杠命令、键盘快捷键、CLI 旗标;同名命令共存时按加载顺序编号 /review:1、/review:2
消息注入sendMessage() · sendUserMessage()注入 custom 消息(进 LLM 上下文)或发送用户消息;支持 deliverAs: steer / followUp / nextTurn 与 triggerTurn
状态持久化appendEntry(customType, data)把扩展数据写入会话 JSONL;custom 条目不进 LLM 上下文,可配 registerEntryRenderer() 在 TUI 里渲染
渲染定制registerMessageRenderer() · registerEntryRenderer() · registerToolRenderer() · registerMarkdownTransformer()分别接管 custom 消息、custom 条目、任意工具调用、Markdown 渲染
模型控制setModel() · getThinkingLevel() · setThinkingLevel()切换模型/思考级别;变更记入会话历史,恢复会话时还原
Provider / MCPregisterProvider() · registerVirtualModel() · registerMcpServer() 及对应 unregister动态注册模型 Provider、虚拟模型(按请求路由到物理模型)、MCP 服务器
进程 / 会话exec() · setSessionName() · setLabel() · getSettings() · events无 shell 执行命令;会话命名与条目标签;读合并后设置;扩展间事件总线

其中 exec() 的结果形状是 { stdout, stderr, code, killed, truncated }:超时、中止或超过 maxBuffer(默认 256 MiB)都会置 killed: true,命令忽略 SIGTERM 时 5 秒后升级为 SIGKILL——比手写 child_process 安全得多,官方示例(git-checkpoint 等)一律走它。

§11.4事件目录与处理器组合

Pi 的事件面非常宽,但可以按「发生在哪个阶段」分成八类。先看目录,再看本讲的核心图示——扩展生命周期与事件流总图。

① 启动 Startup

进程启动与项目信任决策

  • project_trust 可接管
  • 仅全局与 -e 扩展参与;返回 {trusted, remember},第一个 yes/no 获胜

② 资源 Resource

技能 / 提示词 / 主题 / MCP 资源贡献

  • resources_discover(在 session_start 之后)
  • mcp_servers_change

③ 会话 Session

会话生命周期与结构操作

  • session_start / session_shutdown
  • session_before_switch / session_before_fork 可取消
  • session_before_compact / session_compact / session_compact_failed
  • session_before_tree / session_tree / session_info_changed

④ Agent 运行

一次 run 与其中的 turn

  • before_agent_start(注入消息/改系统提示)
  • agent_start / agent_end
  • agent_before_settle / agent_settled
  • turn_start / turn_end(边界,可请求续跑)

⑤ 模型 / 请求 Model

每次 LLM 请求的钩子链

  • context → context_with_system
  • before_provider_headers(就地改请求头)
  • before_provider_request(替换 payload)
  • after_provider_response / provider_stream_event
  • message_start / update / end
  • model_select / thinking_level_select
  • cache_warming_decision

⑥ 工具 Tool

每个 tool call 的执行钩子

  • tool_execution_start
  • tool_call 可 block / 就地改 input
  • tool_execution_update(执行中)
  • tool_result 链式改写结果
  • tool_execution_end(每个终结调用必有)

⑦ 用户 bash

! / !! 内嵌命令

  • user_bash 可拦截:返回 {operations} 换执行后端(SSH/沙箱),返回 {result} 直接记录结果,返回 undefined 落回本地执行

⑧ 输入 Input

用户输入进入 Agent 前

  • input(扩展命令之后、技能/模板展开之前;返回 handled / transform / continue)
  • ui_prompt_start / ui_prompt_end(阻塞式 UI 提示的包裹通知)
A · 进程启动 B · 一次 Agent 运行 C · 会话替换 / 退出 进程启动 load extensions project_trust 仅需要时 · 可接管决策 信任前不加载项目扩展 session_start reason: startup|reload… resources_discover 贡献 skill/prompt/theme session_info_changed /name 改名等 进入待命状态 input 命令后·展开前 before_agent_start 注入消息/改系统提示 agent_start 一次 run 开始 turn 循环(每 turn = 一次模型请求 + 它触发的工具执行) turn_start turnIndex context → context_with_system → headers → payload → resp → stream 模型请求钩子链(每请求一次) exec_start → tool_call → update → tool_result → exec_end tool_call 可 block · 见 §11.5 还有 turn? turn_end 边界:可追加条目/续跑 agent_end agent_end 不是最终空闲:重试 / 压缩重试 / 队列消息可能再次 agent_start agent_before_settle 最后的可操作边界 agent_settled 通知-only · ctx.isIdle() 等待下一个输入 / 会话操作 不替换 runtime 的其他操作(各自独立的小生命周期) /compact 或自动压缩 → session_before_compact → session_compact | session_compact_failed /tree 导航 → session_before_tree → session_tree ! 或 !! 用户命令 → user_bash(可换成远程/沙箱 operations) 扩展阻塞式对话框 → ui_prompt_start … ui_prompt_end 注册时机与状态边界 registerTool / registerCommand / on 可在工厂期与运行期(session_start、命令、事件处理器内)调用;动态注册的工具立即刷新进当前会话。sendMessage 等动作方法 在 runtime 绑定前调用会抛错——工厂里只做「注册 + 读配置」。捕获的旧 pi/ctx 在会话替换后失效,必须在 withSession 里用新 ctx。 /new /resume /fork /reload · exit(Ctrl+C/D) session_shutdown reason: quit|reload|new |resume|fork 替换 runtime? exit → 进程退出 重新加载扩展 runtime → session_start reason: reload/new/resume/fork 回到 A 泳道的 session_start:扩展重挂载、内存态重建
依据 docs/extensions.md 的 Lifecycle Overview 三张官方 mermaid 图与 loader.ts、runner.ts 源码整理。官方三图分别覆盖:一次运行的输入 → run → turn 结构、每个 turn 内模型请求与工具调用的钩子顺序、会话启动 / 替换 / 退出的独立生命周期。

处理器组合:六种语义,决定你的返回值怎么被对待

多个扩展订阅同一事件时,处理器按「扩展加载顺序 → 扩展内注册顺序」执行。但返回值如何组合,事件与事件不同——文档概括为六种模式,写扩展前必须对号入座:

模式行为代表事件
通知 Notification返回值被忽略,仅观察agent_settled、provider_stream_event、session_compact
就地修改 In-place mutation后序处理器看到你改过的值before_provider_headers.headers、before_agent_start.systemPromptOptions、tool_call.input
链式转换 Chained后面的处理器收到前面返回的新值input、context、context_with_system、message_end、tool_result、before_provider_request
门禁 Gate第一个「已处理 / 已阻止」的结果终止正常回退路径input 的 handled、tool_call 的 block、会话操作的 cancel、user_bash 的 {operations}/{result}
可操作边界 Boundary每个处理器看到此前累计的 entries 提案与续跑决策turn_end、agent_before_settle
最后覆盖 Last override最后一个返回 action 的处理器获胜cache_warming_decision

两条官方工程箴言:通知型处理器不要在流式事件上做慢活(provider_stream_event 的处理器按流序 await,慢了会拖慢整个流消费);门禁型处理器在无法做出安全判断时应 fail closed(宁可阻止)。

§11.5tool_call 拦截与 block 语义

tool_call 在 tool_execution_start 之后、工具真正执行之前触发,是扩展系统最高频的用途——权限门、路径保护、命令改写都挂在这里。官方的行为保证有五条,全部值得逐字记住:

  • event.input 是可变的:就地修改会真实作用于工具执行;
  • 后序 tool_call 处理器能看到先序处理器的修改;
  • 修改后不会重新做参数校验(pi-schema 的 JSON Schema 校验发生在你之前);
  • 返回值通过 { block: true, reason?, terminate? } 控制拦截;
  • terminate 只对被阻止的调用生效,且只有当本批全部终结结果都 terminate 时才会让 agent 提前停止。

block 之后发生什么?很多人以为循环会中断——不会。看内核源码的消费点:被阻止的调用被包装成一个错误工具结果回填,循环照常继续,模型会读到这条错误并自行决定下一步(道歉、换方案或再次尝试):

packages/agent/src/agent-loop.ts(L755 附近)节选
if (beforeResult?.block) {
  const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
  if (beforeResult.terminate === true) {
    result.terminate = true;
  }
  return { kind: "immediate", result, isError: true };
}
packages/agent/src/types.ts(BeforeToolCallResult 文档注释)节选
/**
 * Returning `{ block: true }` prevents the tool from executing. The loop emits
 * an error tool result instead. `reason` becomes the text shown in that error
 * result. If omitted, a default blocked message is used.
 */
export interface BeforeToolCallResult {
  block?: boolean;
  reason?: string;
  /** Early termination only happens when every finalized tool result in the batch sets this to true. */
  terminate?: boolean;
}

拦截器在 runner.ts 的 emitToolCall() 里串行执行:一旦某个处理器返回 block: true,立即返回,不再调用后续处理器——这就是「门禁」模式。官方最短而完整的权限门示例(注意它对无 UI 环境的 fail-closed 处理):

packages/coding-agent/examples/extensions/permission-gate.ts全文
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

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

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

    const command = event.input.command as string;
    const isDangerous = dangerousPatterns.some((p) => p.test(command));

    if (isDangerous) {
      if (!ctx.hasUI) {
        // In non-interactive mode, block by default
        return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
      }
      const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n  ${command}\n\nAllow?`, ["Yes", "No"]);
      if (choice !== "Yes") {
        return { block: true, reason: "Blocked by user" };
      }
    }
    return undefined;
  });
}

路径保护是同一模式的变体,把检查对象从 bash 的 command 换成 write / edit 的 path:protected-paths.ts(同目录,约 27 行)对 .env、.git/、node_modules/ 直接返回 { block: true, reason: 'Path "..." is protected' },有 UI 时先 notify 提示用户。本讲动手实践会把它升级成「需确认」版本。

类型缩窄用官方类型守卫:内置工具直接 isToolCallEventType("bash", event),event.input 即获得精确类型;自定义工具导出 schema 的 Static 类型后用 isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)。另外两条容易被忽略的执行模型:并行工具模式下,同一 assistant 消息的兄弟工具调用会先串行预检(preflight)再并发执行,因此 tool_call 里不保证能从 ctx.sessionManager 看到兄弟工具的结果;tool_call 派发前,Pi 会先等此前发出的 Agent 事件排空,所以会话状态至少同步到当前这条 assistant 消息。

§11.6ctx.ui:扩展的完整交互面板

每个事件处理器的第二个参数 ctx: ExtensionContext 里,ctx.ui 是与用户交互的全部出口。接口定义在 types.ts 的 ExtensionUIContext,按能力分层:

能力层方法说明与可用模式
对话框(阻塞式)select(title, options, opts?)单选;取消/超时返回 undefined
confirm(title, message, opts?)确认;取消/超时返回 false
input(title, placeholder?, opts?)单行文本输入
editor(title, prefilled?)多行编辑器
通知notify(message, "info"|"warning"|"error")非阻塞提示
状态展示setStatus(key, text?)页脚持久状态槽,传 undefined 清除
setWidget(key, content?, opts?)编辑器上方(默认)/下方挂小部件;接受字符串数组或组件工厂,placement: "belowEditor" 可选
setWorkingMessage() / setWorkingIndicator() / setHiddenThinkingLabel() / setTitle() / setHeader() / setFooter()流式加载文案与指示器、思考块标签、终端标题、启动头部与整条页脚替换
编辑器与输入setEditorText() / getEditorText() / pasteToEditor()读写主输入框、以粘贴语义注入
onTerminalInput(handler)原始终端输入流;返回 {consume:true} 可吞掉按键(仅 TUI)
addAutocompleteProvider(factory) / setEditorComponent() / getEditorComponent()叠加自动补全;替换整个编辑器组件(Vim 模式等,扩展 CustomEditor)
全屏 / 覆盖层custom<T>(factory, opts?)获得键盘焦点的自定义 TUI 组件;支持 overlay 定位与 blocked 状态上报;仅 TUI,RPC 下返回 undefined

对话框还支持 opts.timeout(带倒计时自动关闭)与 opts.signal(用 AbortSignal 区分超时与用户取消)。summarize.ts 展示了「命令 → 模型调用 → custom 全屏展示」的完整链路,其 UI 收尾段(节选):

packages/coding-agent/examples/extensions/summarize.ts节选
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
  if (ctx.mode !== "tui") {          // 模式守卫:非 TUI 直接放弃渲染
    return;
  }
  await ctx.ui.custom((_tui, theme, _kb, done) => {
    const container = new Container();
    // …DynamicBorder + Markdown(summary) 组装滚动视图…
    return {
      render: (width: number) => container.render(width),
      invalidate: () => container.invalidate(),
      handleInput: (data: string) => {
        if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
          done(undefined);      // 关闭全屏组件
        }
      },
    };
  });
};
Java 视角 ②

ctx.ui 的角色与 Swing 的 EDT(Event Dispatch Thread)调度一致:你的扩展代码跑在 agent 的执行路径上,但用户可见的一切必须经宿主的渲染循环投递——ctx.ui.notify() 之于 SwingUtilities.invokeLater(),ctx.ui.custom() 之于把一个自定义 JComponent 交给 JFrame 并接管 KeyEvent。RPC 模式则像把显示器换成远程协议:调用仍可用,但渲染发生在客户端,组件工厂自然无从谈起;print/JSON 模式干脆没有显示器,对话方法需要先看 ctx.hasUI。

§11.7状态持久化与跨会话恢复

扩展状态有三层,语义各不相同,选错层是最常见的返工原因:

  • 模块内存变量——最便宜,但 /reload、会话替换、进程退出后全部归零;
  • 工具结果 details——随 toolResult 消息进入会话 JSONL,天然跟随分支(branch)语义,适合「业务状态」:恢复时扫描当前分支 ctx.sessionManager.getBranch() 而非 getEntries()(被放弃的分支是平行历史,不应参与重建);
  • pi.appendEntry(customType, data)——把任意 JSON 作为 custom 条目写进会话日志:不进 LLM 上下文、不影响模型行为,可配 registerEntryRenderer() 在 TUI 里画出状态卡片,重启/恢复会话后仍在。

appendEntry + 恢复的官方骨架:

packages/coding-agent/docs/extensions.md(appendEntry 节选)节选
pi.appendEntry("my-state", { count: 42 });
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });

// Restore on reload / resume / fork
pi.on("session_start", async (_event, ctx) => {
  for (const entry of ctx.sessionManager.getEntries()) {
    if (entry.type === "custom" && entry.customType === "my-state") {
      // Reconstruct from entry.data
    }
  }
});
packages/coding-agent/docs/extensions.md(registerEntryRenderer 节选)节选
import { Box, Text } from "@earendil-works/pi-tui";

pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
  const data = entry.data as { title: string; count: number };
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
  box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
  if (expanded) {
    box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
  }
  return box;
});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
Java 视角 ③

这正是事件溯源(Event Sourcing)的分层:appendEntry 写的是「不参与业务决策的审计事件」,tool_result.details 是「进聚合重放的业务事件」,内存变量只是本进程的缓存。重建状态要用 getBranch()(当前分支 = 聚合的有效历史),不能拿全量 getEntries() 重放——后者把所有被放弃的分支也算了进去,相当于用失败事务的旧值覆盖最终状态。

§11.8四种运行模式下的降级行为

同一份扩展代码会跑在四种模式里,可用能力完全不同。两张守卫牌必须形成条件反射:ctx.hasUI(TUI 与 RPC 为 true)守对话框与通知;ctx.mode === "tui" 守 custom()、组件工厂、原始终端输入这类纯终端能力。

模式ctx.modectx.hasUI行为要点
交互式(默认)"tui"true完整终端渲染,所有 ctx.ui 能力可用
RPC(--mode rpc)"rpc"true对话框与通知经 JSON 协议转发给 RPC 客户端渲染;custom() 返回 undefined,组件工厂被忽略(详见 rpc-extension-ui.md)
JSON(--mode json)"json"false事件流写 stdout,UI 方法全部 no-op
Print(-p)"print"false扩展照常运行(拦截、注入仍有效),但无法向用户提问

所以「print 模式为什么不能弹 confirm」的答案是结构性的:对话框需要一个能等待人类输入的通道,print/JSON 模式的 stdout 是给程序消费的流,没有交互回路。正确做法就是 permission-gate.ts 的写法——if (!ctx.hasUI) return { block: true, reason: "… (no UI for confirmation)" }:无法确认时按拒绝处理,宁可保守。同理,summarize.ts 的全屏 UI 前有 ctx.mode !== "tui" 提前返回,git-checkpoint 在无 UI 时跳过「是否恢复代码」的询问而不自动恢复。

§11.9官方示例地图:三个代表精读

examples/extensions/ 目录是活的 API 文档(docs 末尾的 Examples Reference 按用途索引了全部 60+ 个示例)。本讲精读的三个代表分别覆盖「拦截」「状态 + 命令」「TUI 深水区」:

示例演示能力关键 API
permission-gate.ts(33 行)权限门:正则识别 rm -rf/sudo/chmod 777,select 确认,无 UI fail-closedon("tool_call")、ctx.ui.select、ctx.hasUI、{block, reason}
git-checkpoint.ts(51 行)git 检查点:每 turn git stash create 存快照,fork 时询问是否恢复on("turn_start")、pi.exec、内存 Map + entryId 关联、session_before_fork、agent_settled
summarize.ts(约 160 行)会话总结命令:扫分支消息 → 调模型 → custom 全屏 Markdown 展示registerCommand、ctx.modelRegistry.complete、ctx.ui.custom、matchesKey
packages/coding-agent/examples/extensions/git-checkpoint.ts节选
const checkpoints = new Map<string, string>();
let currentEntryId: string | undefined;

pi.on("tool_result", async (_event, ctx) => {
  const leaf = ctx.sessionManager.getLeafEntry();
  if (leaf) currentEntryId = leaf.id;
});

pi.on("turn_start", async () => {
  // Create a git stash entry before LLM makes changes
  const { stdout } = await pi.exec("git", ["stash", "create"]);
  const ref = stdout.trim();
  if (ref && currentEntryId) {
    checkpoints.set(currentEntryId, ref);
  }
});

pi.on("session_before_fork", async (event, ctx) => {
  const ref = checkpoints.get(event.entryId);
  if (!ref) return;
  if (!ctx.hasUI) return;  // 非 TUI 不自动恢复
  const choice = await ctx.ui.select("Restore code state?", [
    "Yes, restore code to that point", "No, keep current code",
  ]);
  if (choice?.startsWith("Yes")) {
    await pi.exec("git", ["stash", "apply", ref]);
    ctx.ui.notify("Code restored to checkpoint", "info");
  }
});

想继续深入,按主题再挑三个即可:custom-compaction.ts(接管压缩摘要)、snake.ts(约 300 行,定时器 + 键盘 + 组件缓存的全功能 TUI 游戏,还演示了保存游戏状态并在重启后恢复)、with-deps/(带 npm 依赖的扩展包目录结构)。plan-mode/ 与 gondolin/ 是两个「扩展能走多远」的极限案例,留到第 13、14 讲再用。

§11.10动手实践:写一个真实可用的 review-gate 扩展

综合任务来自课程大纲:拦截对 .env 与 node_modules 的写入需确认;注册 /review-checklist 命令;用 appendEntry 把每次拦截持久化;会话重启后恢复记录。按下面的顺序搭建:

  1. 建文件并快速试载

    创建 .pi/extensions/review-gate.ts(项目内,需项目信任)或 ~/.pi/agent/extensions/review-gate.ts(全局)。开发期用 pi -e ./review-gate.ts 临时加载最快;放入自动发现目录后改用 /reload 热重载,验证「shutdown → 重挂载 → session_start」全流程。

  2. tool_call 拦截 + 模式降级

    订阅 tool_call,用 isToolCallEventType("write", event) 与 isToolCallEventType("edit", event) 缩窄类型;路径含 .env 或 node_modules/ 时:有 UI 用 ctx.ui.confirm("写保护", path) 询问,拒绝则返回 { block: true, reason: "Protected path denied: " + path };无 UI(print/JSON)直接 fail-closed 阻止。对照 permission-gate.ts 的分支结构。

  3. 注册命令与状态展示

    pi.registerCommand("review-checklist", { … }):handler 里用 ctx.ui.notify() 打印检查清单,并用 ctx.ui.setStatus("review-gate", "已拦截 N 次") 在页脚常驻一个计数。

  4. 持久化拦截日志

    每次拦截发生(无论放行还是阻止)调用 pi.appendEntry("review-gate-log", { path, decision, reason, at: Date.now() });再 registerEntryRenderer("review-gate-log", …) 让它在 TUI 会话流里渲染成一行简报。

  5. 跨会话恢复

    在 session_start 里遍历 ctx.sessionManager.getEntries(),过滤 entry.type === "custom" && entry.customType === "review-gate-log" 重建内存计数,然后 ctx.ui.setWidget("review-stats", ["拦截统计", "共 N 次 · 阻止 M 次"]) 显示在编辑器上方。退出 Pi、pi --resume 回到该会话,验证 widget 数字仍在。

  6. 模式回归验证

    分别用 pi(TUI)、pi -p "向 .env 写入 API_KEY"(print)、pi --mode json 各跑一次:确认 TUI 弹确认、print/JSON 静默阻止且事件流里能看到错误 toolResult。最后 /reload 两次确认无泄漏定时器。

§11.11常见误区

误区一:在工厂函数里启动 watcher / 定时器 / 子进程

工厂可能在从不开启会话的调用(如 pi --list-models)中运行。后台资源必须在 session_start(或真正需要它的命令/事件)里启动,并注册幂等的 session_shutdown 清理;snake.ts 的游戏定时器就挂在组件生命周期而非工厂里。

误区二:以为扩展有沙箱或降权

扩展与 Pi 同进程、同 OS 权限,能读提示词、工具调用、文件、凭据与会话历史。一个恶意 .pi/extensions/ 文件在项目通过信任后即获得你账户的全部能力——「只装可信来源」是唯一的防线,第 14 讲的安全部分会展开。

误区三:mutate tool_call.input 后期待重新校验,或忽略后续处理器能看到改动

官方保证是「不 re-validation」且「later handlers see mutations made by earlier handlers」。改写参数要自己保证合法性;多个扩展同时改写同一工具参数时按加载顺序叠加,改动尽量收敛在自己关心的字段上。

误区四:捕获旧的 pi/ctx 跨 newSession()/fork()/switchSession()/reload() 使用

会话替换后旧 runtime 被标记 stale,再调用会抛错(runtime 的 invalidate() 会在错误信息里明确提示)。替换后的工作必须写进 withSession 回调并用传入的新 ctx;此前捕获的裸对象(如旧 sessionManager)同样作废,只允许带出字符串、id 等可序列化数据。

误区五:无 UI 模式下调用 confirm/select 然后「等待」

print/JSON 模式 ctx.hasUI === false,没有交互回路。门禁逻辑必须先判 ctx.hasUI,无 UI 时给出保守默认值(阻止/跳过),像 permission-gate 与 git-checkpoint 那样。

§11.12自测问答

Q1tool_call 事件返回 { block: true, reason } 之后,Agent 循环会发生什么?

工具不会执行。agent-loop.ts 用 createErrorToolResult(reason || "Tool execution was blocked") 生成一个 isError: true 的 toolResult 立即回填,循环继续——模型会读到这条错误并自行决定下一步。若同时 terminate: true,则只有当本批所有终结结果都 terminate 时 agent 才提前停止。派发层面,runner.emitToolCall() 一旦遇到 block 结果立即返回,后续扩展的 tool_call 处理器不再执行。

Q2扩展状态如何跨会话(重启 / resume / fork)存活?

三条路:① 业务状态放进自定义工具结果的 details,随 toolResult 消息持久化,恢复时按当前分支 getBranch() 重放;② 审计/展示类数据用 pi.appendEntry(customType, data) 写成 custom 条目(不进 LLM 上下文),配 registerEntryRenderer 渲染;③ 真正落盘的自有文件(自行读写)。无论哪种,重建都发生在 session_start(reason 覆盖 startup/reload/new/resume/fork),清理发生在 session_shutdown;纯内存变量在重载/替换后必然丢失。

Q3为什么扩展不能在 print 模式弹 confirm?

print(-p)与 JSON 模式下 ctx.hasUI === false:stdout 是供程序消费的一次性输出流,没有等待人类输入的回路,对话框方法不可用。正确做法是模式守卫:无 UI 时按保守策略处理(如 permission-gate 直接 block 并写明 "no UI for confirmation")。TUI 与 RPC 模式 hasUI === true,对话框可用,但 RPC 下 custom() 返回 undefined、组件工厂被忽略。

Q4多个扩展订阅同一事件时,执行顺序与结果组合规则是什么?

顺序固定为「扩展加载顺序(项目本地 → 全局 → 显式配置,目录内按发现顺序)→ 扩展内注册顺序」。组合语义按事件分六种:通知(忽略返回值)、就地修改(headers / systemPromptOptions / tool_call.input)、链式转换(input / context / context_with_system / message_end / tool_result / before_provider_request)、门禁(第一个 block / handled / cancel 即终止)、可操作边界(turn_end、agent_before_settle 逐个看到累计提案)、最后覆盖(cache_warming_decision)。写处理器前先确认目标事件属于哪一类。

Q5pi.appendEntry() 与 pi.sendMessage() 都能往会话里写东西,区别是什么?

sendMessage 注入的是 custom 消息:进入会话历史并参与 LLM 上下文(模型能看到),可设 display 控制是否在 TUI 显示,配 registerMessageRenderer 渲染,还能 triggerTurn: true 触发一次模型响应。appendEntry 写的是 custom 条目:持久化进会话 JSONL 但不发给模型,纯粹给 TUI/导出/恢复用,配 registerEntryRenderer 渲染。一句话:要影响模型用前者,只记状态用后者。

Q6扩展的 npm 依赖是如何被解析的?宿主提供的包能写进 dependencies 吗?

在扩展目录(或父目录)放 package.json 并 npm install,jiti 会就近解析 node_modules/ 里的 import;多入口用 pi.extensions 字段声明。宿主已内置的包(@earendil-works/pi-coding-agent、pi-agent-core、pi-ai、pi-tui、typebox,以及旧的 @mariozechner/*、@sinclair/typebox 名字)被映射到 Pi 自带副本、与宿主共享类和注册表,不应写进 dependencies;通过 pi install 分发的包,运行时依赖必须放 dependencies(devDependencies 不会安装)。