从零开始拆解Pi系列——(7)Extension API

一、引言:Extension API 的地位

前六篇文章讲了 pi agent 的核心骨架和三种扩展方式:

  • 工具:让 agent 能调用 read / bash / edit / write------但工具是框架内置的
  • hook:让外部代码拦截工具调用和循环行为------但 hook 只能"拦截",不能"新增"
  • skill:让 agent 按需加载领域知识------但 skill 只注入 Markdown 指令,不能执行代码

这三种方式覆盖了"用现有能力"和"注入知识"的场景。但如果用户想要的是:

  • 加一个全新的工具(如 search_web)------skill 做不到,hook 做不到
  • 注册一个 /deploy 命令------前面没讲过任何命令注册机制
  • 监听 session 启动事件做初始化------hook 只有工具和循环钩子,没有 session 事件
  • 自定义消息渲染样式------和 agent loop 无关,前面完全没涉及
  • 注册自定义 LLM provider------和工具/hook/skill 都无关

这些需求需要一个能注册代码 的正式接口。Extension API 就是这个接口------它把 pi 的所有扩展点统一成一个 TypeScript 模块系统:写一个 extension.tsexport default function(pi: ExtensionAPI),在函数里调 pi.on(...) / pi.registerTool(...) / pi.registerCommand(...) 注册你想要的一切。

Extension API 的能力分六类:

能力 方法 做什么
事件订阅 on(event, handler) 监听 30+ 种生命周期事件(session / agent / turn / message / tool)
工具注册 registerTool(tool) 注册 LLM 可调用的自定义工具
命令/快捷键/Flag registerCommand / registerShortcut / registerFlag 注册 slash 命令、键盘快捷键、CLI 参数
消息渲染 registerMessageRenderer 自定义消息的 UI 渲染
Actions sendMessage / exec / setModel / setActiveTools 主动操作 agent------发消息、执行命令、切换模型
Provider 注册 registerProvider 注册自定义 LLM provider

这是 pi "扩展无需 fork" 理念的最终落地------skill 注入知识,hook 拦截行为,extension 定义一切。不改 pi 源码,在 .pi/extensions/ 目录下放一个 .ts 文件就能改变 agent 的工具集、行为、命令和 UI。

二、extension 文件格式

extension 是一个 TypeScript 文件,放在 .pi/extensions/ 目录下。pi 启动时自动发现并加载它。

目录结构

perl 复制代码
.pi/
└── extensions/
    ├── dirty-repo-guard.ts        # 一个文件就是一个扩展
    ├── file-trigger.ts
    └── my-custom-tool/
        ├── index.ts                # 入口(default export)
        └── helpers.ts              # 辅助模块

两种形式:

  • 单文件.pi/extensions/dirty-repo-guard.ts------一个 .ts 文件就是一个扩展
  • 目录.pi/extensions/my-custom-tool/index.ts------一个目录,入口是 index.ts,可以带辅助文件

最小示例

以 pi 自带的 dirty-repo-guardexamples/extensions/dirty-repo-guard.ts)为例:

typescript 复制代码
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";

async function checkDirtyRepo(pi: ExtensionAPI, ctx: ExtensionContext, action: string) {
  const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
  if (code !== 0) return;                    // 不是 git 仓库,放行
  if (stdout.trim().length === 0) return;     // 没有未提交变更,放行

  if (!ctx.hasUI) return { cancel: true };    // 非交互模式,直接阻止

  const choice = await ctx.ui.select(
    `You have uncommitted file(s). ${action} anyway?`,
    ["Yes, proceed anyway", "No, let me commit first"],
  );
  if (choice !== "Yes, proceed anyway") {
    ctx.ui.notify("Commit your changes first", "warning");
    return { cancel: true };                  // 用户选择先提交,阻止操作
  }
}

export default function (pi: ExtensionAPI) {
  pi.on("session_before_switch", async (event, ctx) => {
    return checkDirtyRepo(pi, ctx, event.reason === "new" ? "new session" : "switch session");
  });

  pi.on("session_before_fork", async (_event, ctx) => {
    return checkDirtyRepo(pi, ctx, "fork");
  });
}

文件格式约定

每个 extension 文件必须:

  1. export default function(pi: ExtensionAPI) :default export 是一个函数,接收 ExtensionAPI 实例。pi 加载扩展时调这个函数,函数里通过 pi 注册事件、工具、命令等。

  2. 只做注册,不做长时间运行 :default export 函数应该快速返回。如果需要长期监听(如 file-trigger 的 fs.watch),在 on("session_start") handler 里启动监听,不要在 default export 里阻塞。

  3. import 从 @earendil-works/pi-coding-agent :扩展代码可以 import pi 的类型和工具。pi 在加载时把内置模块注入 jiti 的虚拟模块表(loader.ts:44-60),扩展不需要自己安装依赖。

ExtensionAPI 是什么

pi 参数是 ExtensionAPI 接口的实例------它是扩展和 pi 框架之间的唯一桥梁。通过 pi,扩展能:

  • pi.on(event, handler)------订阅事件
  • pi.registerTool(tool)------注册工具
  • pi.registerCommand(name, options)------注册命令
  • pi.sendMessage(message)------发消息给 agent
  • pi.exec(command, args)------执行 shell 命令
  • pi.setModel(model)------切换模型

ExtensionAPI 的完整接口在第 5 章展开。

ExtensionContext

handler 的第二个参数 ctx: ExtensionContext 是调用上下文------提供 UI 原语:

  • ctx.hasUI:是否在交互模式(有 TUI)
  • ctx.ui.notify(message, level):通知用户
  • ctx.ui.select(prompt, options):让用户选择
  • ctx.ui.prompt(prompt):让用户输入

这让扩展能在非交互模式(如 pi -p "...")优雅降级------检查 ctx.hasUI,没有 UI 就跳过交互逻辑。

三、loader 加载

extension 文件放在 .pi/extensions/ 下,但 pi 怎么发现它们、怎么执行 TypeScript 代码?这靠 loader.tscoding-agent/src/core/extensions/loader.ts,600 行)。

1. 入口:loadExtensions

typescript 复制代码
// loader.ts:413-438
export async function loadExtensions(
  paths: string[],         // 扩展文件路径列表
  cwd: string,             // 工作目录
  eventBus?: EventBus,     // 事件总线
): Promise<LoadExtensionsResult> {
  const extensions: Extension[] = [];
  const errors: Array<{ path: string; error: string }> = [];
  const runtime = createExtensionRuntime();   // 创建运行时(handler 注册表等)

  for (const extPath of paths) {
    const { extension, error } = await loadExtension(extPath, cwd, eventBus, runtime);
    if (error) {
      errors.push({ path: extPath, error });
      continue;               // 一个扩展加载失败不影响其他
    }
    if (extension) extensions.push(extension);
  }

  return { extensions, errors, runtime };
}

遍历所有扩展路径,逐个加载。失败的扩展记入 errors,不中断其他扩展的加载------和 skill 的容错设计一致。

2. 单个扩展加载:loadExtension

typescript 复制代码
// loader.ts:368-391
async function loadExtension(
  extensionPath: string,
  cwd: string,
  eventBus: EventBus,
  runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> {
  const resolvedPath = resolvePath(extensionPath, cwd);

  try {
    // 1. 动态 import 扩展模块,拿到 default export(factory 函数)
    const factory = await loadExtensionModule(resolvedPath);
    if (!factory) {
      return { extension: null, error: `Extension does not export a valid factory function` };
    }

    // 2. 创建 Extension 对象(空的 handler/tool/command 集合)
    const extension = createExtension(extensionPath, resolvedPath);

    // 3. 创建 ExtensionAPI 实例(绑定 extension + runtime + eventBus)
    const api = createExtensionAPI(extension, runtime, cwd, eventBus);

    // 4. 调用 factory(api)------扩展在此时注册 handler/tool/command
    await factory(api);

    return { extension, error: null };
  } catch (err) {
    return { extension: null, error: `Failed to load extension: ${err.message}` };
  }
}

四步:

  1. loadExtensionModule ------动态 import .ts 文件,拿到 default export。这是加载的核心(下方详讲)。
  2. createExtension ------创建一个空的 Extension 对象,内含空的 handlers / tools / commands / flags / shortcuts 等 Map。
  3. createExtensionAPI ------创建 ExtensionAPI 实例,把 extension 对象、runtime、eventBus、cwd 绑定在一起。pi.on(...) / pi.registerTool(...) 等方法的实现就是往 extension 的 Map 里塞东西。
  4. factory(api) ------调用扩展的 default export 函数。扩展在这个函数里调 pi.on(...) 注册事件、pi.registerTool(...) 注册工具。注册结果存入 extension 对象的各个 Map。

3. 动态 import:loadExtensionModule

typescript 复制代码
// loader.ts:331-343
async function loadExtensionModule(extensionPath: string) {
  const jiti = createJiti(import.meta.url, {
    moduleCache: false,       // 不缓存------支持热重载
    ...(isBunBinary
      ? { virtualModules: VIRTUAL_MODULES, tryNative: false }  // 编译二进制:用虚拟模块
      : { alias: getAliases() }                                  // 开发模式:用 alias 解析
    ),
  });

  const module = await jiti.import(extensionPath, { default: true });
  const factory = module as ExtensionFactory;
  return typeof factory !== "function" ? undefined : factory;
}

这里用 jiti ------一个 TypeScript 运行时编译器。它让 pi 能直接加载 .ts 文件,不需要预编译。

两种模式:

  • Bun 编译二进制模式isBunBinary):pi 打包成单文件二进制时,node_modules 不在文件系统上。jiti 的 virtualModules 把 pi 的内置模块(@earendil-works/pi-ai / pi-agent-core / pi-tui / typebox 等)注入为一个虚拟模块表------扩展 import { ExtensionAPI } from "@earendil-works/pi-coding-agent" 时,jiti 从虚拟表里解析,不找文件系统。
  • 开发模式 :用 alias 把包名映射到 node_modules 里的真实路径。

moduleCache: false 是热重载的关键------每次 loadExtensionModule 都重新编译,不拿缓存。这让 /reload 命令能热重载扩展(第 4 章讲)。

4. createExtension:空壳

typescript 复制代码
// loader.ts:348-366
function createExtension(extensionPath: string, resolvedPath: string): Extension {
  return {
    path: extensionPath,
    resolvedPath,
    sourceInfo: createSyntheticSourceInfo(extensionPath, { source: "local", baseDir: path.dirname(resolvedPath) }),
    handlers: new Map(),          // 事件 handler 注册表
    tools: new Map(),             // 工具注册表
    messageRenderers: new Map(),  // 消息渲染器注册表
    commands: new Map(),          // 命令注册表
    flags: new Map(),             // CLI flag 注册表
    shortcuts: new Map(),         // 快捷键注册表
  };
}

Extension 对象初始全是空 Map。factory(api) 调用后,扩展通过 pi.on(...) / pi.registerTool(...) 往这些 Map 里塞东西。加载完成后,这些 Map 就是扩展的"注册结果"------runner(第 4 章)从里面读 handler 来执行。

5. 加载流程总结

scss 复制代码
loadExtensions(paths)
  └─ for each path:
       └─ loadExtension(path)
            ├─ loadExtensionModule(path)     → jiti 动态编译 .ts,拿到 factory 函数
            ├─ createExtension(path)          → 创建空壳 Extension 对象
            ├─ createExtensionAPI(extension)  → 创建 ExtensionAPI 实例(pi)
            └─ factory(api)                   → 调用扩展代码,注册 handler/tool/command
                  ├─ pi.on("tool_call", handler)  → extension.handlers.set("tool_call", [handler])
                  ├─ pi.registerTool(myTool)      → extension.tools.set("myTool", myTool)
                  └─ pi.registerCommand("deploy")  → extension.commands.set("deploy", {...})

加载完成后,所有 Extension 对象(各自的 Map 已被填充)交给 runner 管理。

四、runner 执行

loader 加载完扩展后,Extension 对象的各个 Map 已被填充。但这些 handler 什么时候被调用?谁负责调度?这靠 ExtensionRunnerrunner.ts,1092 行)。

Extension API 的核心组件关系:

classDiagram class AgentSession { -_extensionRunner: ExtensionRunner +agent.beforeToolCall: BeforeToolCall +agent.afterToolCall: AfterToolCall +reload(): Promise~void~ } class ExtensionRunner { -extensions: Extension[] -runtime: ExtensionRuntime -uiContext: ExtensionUIContext +hasHandlers(eventType): boolean +emitToolCall(event): ToolCallEventResult +emitToolResult(event): ToolResultEventResult +emit(event): void +createContext(): ExtensionContext } class Extension { +path: string +handlers: Map~string, List~Handler~ } class ExtensionContext { +hasUI: boolean +ui: UIContext +reload(): Promise~void~ +newSession(): Promise~void~ } class Handler { <<function>> +invoke(event, ctx): Promise~HandlerResult~ } AgentSession --> ExtensionRunner : _extensionRunner ExtensionRunner --> Extension : extensions[] Extension --> Handler : handlers.get(eventType) ExtensionRunner ..> ExtensionContext : createContext() Handler ..> ExtensionContext : 第二参数 ctx
  • AgentSession 持有 ExtensionRunner,通过 beforeToolCall / afterToolCall 委托给它
  • ExtensionRunner 持有 Extension[],按事件类型从各自的 handlers Map 里取 handler 执行
  • ExtensionRunner.createContext() 生成 ExtensionContext,作为 handler 的第二参数传入
  • 每个 Extension 是一个注册容器------handlers / tools / commands / flags / shortcuts / messageRenderers 六个 Map

1. hasHandlers + emit:分派机制

文章 5 讲过,AgentSession._installAgentToolHooksbeforeToolCall / afterToolCall 委托给 Extension Runner:

typescript 复制代码
// agent-session.ts:404-423(文章 5 讲过)
this.agent.beforeToolCall = async ({ toolCall, args }) => {
  const runner = this._extensionRunner;
  if (!runner.hasHandlers("tool_call")) {
    return undefined;                    // 没有扩展注册 tool_call handler → 放行
  }
  return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, ... });
};

Runner 的 hasHandlersemitToolCall 是这么实现的:

typescript 复制代码
// runner.ts:497-505
hasHandlers(eventType: string): boolean {
  for (const ext of this.extensions) {
    const handlers = ext.handlers.get(eventType);
    if (handlers && handlers.length > 0) return true;
  }
  return false;
}

hasHandlers 遍历所有 extension,检查有没有人注册了这个事件的 handler。没有就返回 false------beforeToolCall 直接 return undefined 放行。

typescript 复制代码
// runner.ts:819-840
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
  const ctx = this.createContext();       // 创建 ExtensionContext(含 UI 原语)
  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;                 // 任何一个 handler block 了,立即返回
        }
      }
    }
  }

  return result;
}

emitToolCall 遍历所有 extension 的所有 handler,按注册顺序执行 。如果某个 handler 返回 { block: true },立即短路返回------不调后续 handler。

2. emitToolResult:链式修改

tool_result 事件(afterToolCall 对应)的处理不同------handler 不拦截,而是链式修改结果:

typescript 复制代码
// runner.ts:769-817(简化)
async emitToolResult(event: ToolResultEvent): Promise<ToolResultEventResult | undefined> {
  const ctx = this.createContext();
  const currentEvent = { ...event };      // 拷贝------handler 修改拷贝不改原始
  let modified = false;

  for (const ext of this.extensions) {
    const handlers = ext.handlers.get("tool_result");
    if (!handlers || handlers.length === 0) continue;

    for (const handler of handlers) {
      try {
        const handlerResult = await handler(currentEvent, ctx);
        if (!handlerResult) continue;

        // 字段级覆盖------每个 handler 看到前一个 handler 的修改
        if (handlerResult.content !== undefined) {
          currentEvent.content = handlerResult.content;
          modified = true;
        }
        if (handlerResult.details !== undefined) {
          currentEvent.details = handlerResult.details;
          modified = true;
        }
        if (handlerResult.isError !== undefined) {
          currentEvent.isError = handlerResult.isError;
          modified = true;
        }
      } catch (err) {
        this.emitError({ extensionPath: ext.path, event: "tool_result", error: err.message, stack: err.stack });
      }
    }
  }

  return modified ? { content: currentEvent.content, details: currentEvent.details, isError: currentEvent.isError } : undefined;
}

关键设计------链式传递 :每个 handler 收到的是前一个 handler 修改后的 currentEvent。handler A 脱敏了 content,handler B 看到的就是脱敏后的 content。这让多个 handler 可以叠加处理(如先脱敏再加日志)。

3. 错误隔离

注意 emitToolResult 里的 try-catch(runner.ts:795-804):

typescript 复制代码
} catch (err) {
  this.emitError({
    extensionPath: ext.path,
    event: "tool_result",
    error: err.message,
    stack: err.stack,
  });
}

一个 handler 抛异常不中断后续 handler------记错误、继续。这让扩展之间互不影响:扩展 A 的 handler 崩了,扩展 B 的 handler 照常执行。

emitToolCall(tool_call 事件)没有 try-catch------handler 抛异常会传播到 beforeToolCall,被 prepareToolCall 的 try-catch 接住,转成 error result。这是因为 tool_call 的 block 语义需要异常传播(扩展故意抛异常来阻止工具执行)。

4. 生命周期:热重载

pi 支持运行时热重载扩展------用户输入 /reload,所有扩展重新加载,不需要重启 agent。

typescript 复制代码
// agent-session.ts:2429-2450
async reload(): Promise<void> {
  const previousFlagValues = this._extensionRunner.getFlagValues();

  // 1. 通知所有扩展:要关了
  await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });

  // 2. 重新加载配置和资源
  await this.settingsManager.reload();
  await this._resourceLoader.reload();     // 重新 loadExtensions

  // 3. 重建运行时(新的 ExtensionRunner)
  this._buildRuntime({ activeToolNames: ..., flagValues: previousFlagValues, includeAllExtensionTools: true });

  // 4. 通知所有扩展:重新启动了
  await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
}

四步:

  1. session_shutdown ------给所有扩展的 session_shutdown handler 一个清理机会(关文件监听、释放资源)
  2. reload ------resourceLoader.reload()loadExtensions 重新扫描 .pi/extensions/,用 jitimoduleCache: false 重新编译
  3. 重建 runtime ------创建新的 ExtensionRunner,绑定新的 extension 列表
  4. session_start------给新加载的扩展一个初始化机会

文章 5 讲过 _installAgentToolHooks 的延迟绑定设计------钩子赋值只在启动时做一次,但内部读的是 this._extensionRunner(当前引用)。reload 后 _extensionRunner 被替换,下一次工具调用自动走新 runner,不需要重新安装钩子。

5. 事件分派总结

vbnet 复制代码
agent loop 产生事件
  │
  ├─ tool_call 事件
  │    └─ AgentSession.beforeToolCall → runner.emitToolCall(event)
  │         └─ for each extension:
  │              └─ for each handler:
  │                   └─ handler(event, ctx)
  │                        ├─ return { block: true } → 短路返回
  │                        ├─ return undefined → 继续
  │                        └─ throw → 传播到 prepareToolCall → error result
  │
  └─ tool_result 事件
       └─ AgentSession.afterToolCall → runner.emitToolResult(event)
            └─ for each extension:
                 └─ for each handler:
                      └─ handler(currentEvent, ctx)  ← 链式传递
                           ├─ return { content: ... } → 修改 currentEvent
                           ├─ return undefined → 不修改
                           └─ throw → 记错误,继续后续 handler

两种事件两种语义:tool_call短路拦截 (block 就停),tool_result链式修改 (每个 handler 叠加修改)。其他事件(session_start / message_end 等)走类似的分派机制,但不返回结果------纯通知。

五、ExtensionAPI 核心接口

ExtensionAPItypes.ts:1093-1577)是扩展和 pi 框架之间的唯一桥梁。六大类能力:

1. 事件订阅

typescript 复制代码
on(event: string, handler: ExtensionHandler): void;

30+ 种事件,覆盖 pi 的完整生命周期。按阶段分组:

阶段 事件 能拦截/修改?
Session session_start / session_shutdown / session_before_switch / session_before_fork / session_before_compact / session_compact / session_before_tree / session_tree before_* 系列可返回 { cancel: true } 拦截
Provider before_provider_request / after_provider_response before_* 可修改 payload
Agent before_agent_start / agent_start / agent_end before_* 可注入消息
Turn turn_start / turn_end 纯通知
Message message_start / message_update / message_end message_end 可修改消息
Tool tool_call / tool_result / tool_execution_start / tool_execution_update / tool_execution_end tool_call 可 block,tool_result 可链式修改
其他 model_select / thinking_level_select / user_bash / input / resources_discover / context 各有特定返回值

handler 签名:(event: EventData, ctx: ExtensionContext) => Promise<HandlerResult | undefined>

  • 返回 undefined → 不影响
  • 返回 HandlerResult → 拦截 / 修改 / 注入,取决于事件类型
  • 抛异常 → 取决于事件(tool_call 传播,tool_result 隔离)

2. 工具注册

typescript 复制代码
registerTool<TParams extends TSchema, TDetails, TState>(
  tool: ToolDefinition<TParams, TDetails, TState>,
): void;

注册一个 LLM 可调用的工具。ToolDefinition 就是文章 4 讲的 AgentTool + TUI 渲染钩子(renderCall / renderResult)。注册后工具自动加到 context.tools,LLM 下一轮就能看到并调用。

和直接塞 context.tools 的区别:通过 registerTool 注册的工具会带上 sourceInfo(来源是哪个 extension),UI 可以标注"这个工具来自 xxx 扩展"。

3. 命令 / 快捷键 / Flag

typescript 复制代码
// 注册 slash 命令(如 /deploy)
registerCommand(name: string, options: {
  description?: string;
  getArgumentCompletions?: (prefix: string) => CompletionItem[] | null;
  handler: (args: string, ctx: ExtensionContext) => Promise<void> | void;
}): void;

// 注册键盘快捷键
registerShortcut(shortcut: KeyId, options: {
  description?: string;
  handler: (ctx: ExtensionContext) => Promise<void> | void;
}): void;

// 注册 CLI flag(如 pi --deploy)
registerFlag(name: string, options: {
  description?: string;
  type: "boolean" | "string";
  default?: boolean | string;
}): void;

getFlag(name: string): boolean | string | undefined;

三类注册各有场景:

  • 命令 :用户在交互模式输入 /deploy 触发------handler 里可以做任何事(调工具、发消息、执行命令)
  • 快捷键 :用户按某个键触发------如 Ctrl+D 触发部署
  • Flag :用户启动时 pi --deploy 传入------扩展在 session_start 里用 getFlag("deploy") 读取

4. 消息渲染

typescript 复制代码
registerMessageRenderer<T = unknown>(
  customType: string,
  renderer: MessageRenderer<T>,
): void;

注册自定义消息类型的渲染器。扩展通过 sendMessage({ customType: "deploy-status", content: ... }) 发送自定义消息后,TUI 用注册的 renderer 渲染它。

这让扩展能在对话里插入富 UI 元素------如部署进度条、构建结果卡片、图表------不改变 agent loop,只改变展示。

5. Actions

typescript 复制代码
// 发自定义消息(可触发 turn)
sendMessage<T>(message: { customType: string; content: string; display: boolean; details?: T },
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): void;

// 发用户消息(总是触发 turn)
sendUserMessage(content: string | (TextContent | ImageContent)[],
  options?: { deliverAs?: "steer" | "followUp" }): void;

// 追加 session 持久化条目(不发 LLM)
appendEntry<T>(customType: string, data?: T): void;

// 执行 shell 命令
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;

// 工具管理
getActiveTools(): string[];
getAllTools(): ToolInfo[];
setActiveTools(toolNames: string[]): void;

// 模型管理
setModel(model: Model<any>): Promise<boolean>;
getThinkingLevel(): ThinkingLevel;
setThinkingLevel(level: ThinkingLevel): void;

// Session 管理
setSessionName(name: string): void;
getSessionName(): string | undefined;
setLabel(entryId: string, label: string | undefined): void;

// 命令查询
getCommands(): SlashCommandInfo[];

Actions 是扩展主动操作 agent 的 API------和事件订阅(被动监听)互补。关键方法:

  • sendMessage :往对话里注入消息。triggerTurn: true 会触发 LLM 回复。deliverAs 控制消息怎么排队(steer = 中途插话,followUp = 追加任务,nextTurn = 下一轮)------这直接映射文章 5 讲的 steering / followUp 队列。
  • exec :执行 shell 命令,返回 stdout / stderr / exitCode。扩展不用自己 spawn 子进程------通过 pi.exec 走 pi 的命令执行基础设施。
  • setActiveTools:动态切换工具集------如扩展检测到用户在生产环境,切到 read-only 工具。

6. Provider 注册

typescript 复制代码
registerProvider(name: string, config: {
  baseUrl?: string;            // API endpoint
  apiKey?: string;             // API key(支持 $ENV_VAR 变量)
  api?: Api;                   // API 类型(openai-completions / anthropic-messages / ...)
  models?: Model[];            // 模型列表
  oauth?: OAuthConfig;         // OAuth 登录支持
  streamSimple?: StreamFunction; // 自定义 stream 函数
}): void;

注册自定义 LLM provider。三种用法:

  • 注册全新 provider :提供 baseUrl + apiKey + models------如公司内部的 LLM 代理
  • 覆盖现有 provider :只提供 baseUrl------如把 Anthropic 的请求转发到代理服务器
  • 注册 OAuth provider :提供 oauth 配置------用户可以通过 /login 命令 OAuth 登录

这是 pi 多 provider 支持的扩展机制------文章 1-2 讲的 streamSimple 是内置 provider,registerProvider 让第三方加自己的。

六、完整例子:file-trigger 扩展

前五章讲了 Extension API 的接口和机制。本章用一个真实扩展走一遍完整路径------从文件格式到 loader 加载到 runner 执行到 handler 被调用。

1. 完整代码

pi 自带的 file-triggerexamples/extensions/file-trigger.ts,41 行):

typescript 复制代码
/**
 * File Trigger Extension
 *
 * Watches a trigger file and injects its contents into the conversation.
 * Useful for external systems to send messages to the agent.
 *
 * Usage:
 *   echo "Run the tests" > /tmp/agent-trigger.txt
 */

import * as fs from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  // ① 订阅 session_start 事件------agent 启动时触发
  pi.on("session_start", async (_event, ctx) => {
    const triggerFile = "/tmp/agent-trigger.txt";

    // ② 启动文件监听------外部系统写文件就能触发 agent
    fs.watch(triggerFile, () => {
      try {
        const content = fs.readFileSync(triggerFile, "utf-8").trim();
        if (content) {
          // ③ 把文件内容作为自定义消息注入对话
          pi.sendMessage(
            {
              customType: "file-trigger",
              content: `External trigger: ${content}`,
              display: true,
            },
            { triggerTurn: true },   // ④ triggerTurn: true → 触发 LLM 回复
          );
          fs.writeFileSync(triggerFile, "");  // ⑤ 清空文件,防止重复触发
        }
      } catch {
        // 文件可能还不存在------静默忽略
      }
    });

    // ⑥ 通知用户监听已启动
    if (ctx.hasUI) {
      ctx.ui.notify(`Watching ${triggerFile}`, "info");
    }
  });
}

逐段注释:

pi.on("session_start", ...) ------订阅 session 启动事件。agent 每次启动(或 /reload 重载)时,runner 调这个 handler。handler 里启动文件监听------不能在 export default 函数体里直接启动,因为那会在加载阶段阻塞。

fs.watch(triggerFile, ...) ------Node.js 的文件监听 API。监听 /tmp/agent-trigger.txt,文件被修改时触发回调。这让外部系统(如 CI/CD、脚本、另一个进程)能通过写文件来给 agent 发消息。

pi.sendMessage({ customType: "file-trigger", content: ... }) ------Actions API,往对话里注入自定义消息。customType 标识消息类型(UI 可以用 registerMessageRenderer 自定义渲染),content 是消息文本,display: true 让消息在 UI 显示。

{ triggerTurn: true } ------关键参数。注入消息后立即触发 LLM 回复------相当于用户发了一条消息。如果不传 triggerTurn,消息只是存入对话历史,不触发 agent loop。

fs.writeFileSync(triggerFile, "") ------清空触发文件。防止 fs.watch 的重复触发(文件内容还在,下次 watch 事件又读一遍)。

ctx.ui.notify(...) ------通知用户"监听已启动"。ctx.hasUI 检查是否在交互模式------非交互模式(pi -p "...")没有 UI,跳过通知。

2. 调用链流程

flowchart TD A([agent 启动]) --> B[loader.loadExtension<br/>jiti 编译 file-trigger.ts] B --> C[factory<br/>pi.on 注册 session_start handler] C --> D[runner.bindExtensions<br/>handler 存入 extension.handlers] D --> E[runner.emit session_start] E --> F[handler 执行<br/>fs.watch 启动文件监听] F --> G([agent 正常运行]) H([外部系统写文件<br/>echo 'Run tests' > trigger.txt]) --> I[fs.watch 回调触发] I --> J[pi.sendMessage<br/>注入 External trigger: Run tests] J --> K[triggerTurn: true<br/>触发 agent loop] K --> L([LLM 收到消息<br/>开始处理 Run tests]) L --> M[清空 trigger.txt<br/>防止重复触发]

完整路径分两段:

加载段(agent 启动时):

  1. loader.loadExtension 用 jiti 编译 file-trigger.ts,拿到 factory 函数
  2. factory(pi) 执行------pi.on("session_start", handler) 把 handler 存入 extension.handlers
  3. runner 绑定 extensions
  4. session_start 事件触发------runner 从 handlers Map 里取出 handler 执行
  5. handler 里 fs.watch 启动文件监听

运行段(外部触发时):

  1. 外部系统写文件 → fs.watch 回调触发
  2. 读文件内容 → pi.sendMessage 注入消息 + triggerTurn: true
  3. sendMessage 内部调 AgentSession 的消息注入机制------走文章 5 讲的 steering / followUp 队列
  4. triggerTurn: true 让 agent loop 开始新一轮------LLM 看到 "External trigger: Run tests"
  5. LLM 按消息内容执行任务(如调 bash 跑测试)
  6. 清空触发文件

3. 这个例子展示了什么

Extension API 能力 在代码里怎么用
事件订阅 pi.on("session_start", handler)
Actions pi.sendMessage(message, { triggerTurn: true })
ExtensionContext ctx.hasUI + ctx.ui.notify(...)
文件格式 export default function(pi)
热重载 session_start/reload 后再次触发,重新 fs.watch

41 行代码覆盖了 Extension API 的核心路径:文件格式 → 事件订阅 → Actions → Context。没有注册工具 / 命令 / Provider------那些是同类 API,使用模式一样(pi.registerTool(...) / pi.registerCommand(...)),只是注册的东西不同。

4. 实际使用

把文件放到 .pi/extensions/file-trigger.ts,启动 pi:

shell 复制代码
$ pi
Watching /tmp/agent-trigger.txt

# 在另一个终端:
$ echo "Run the tests" > /tmp/agent-trigger.txt

# pi 里自动收到消息:
External trigger: Run the tests
[agent 开始执行测试]

外部系统(如 CI/CD pipeline、cron job、另一个 agent)通过写文件就能给 pi 发指令------不需要 API 调用、不需要 WebSocket、不需要改 pi 源码。这就是 Extension API 的价值------41 行 TypeScript 就能扩展 agent 的输入通道。

七、下一章预告

下一篇文章将进入 pi 的 compaction 机制------长对话如何压缩历史消息、context window 快满时怎么裁剪、branch summarization 如何保留关键信息而不丢失上下文。compaction 是 agent 能持续运行的基础------没有它,长任务会在几十轮后撞上 context window 上限。这是文章 3 讲的 prepareNextTurn 钩子在 pi 真实场景里最重要的应用。

相关推荐
plainGeekDev1 小时前
Agent代码审查与批量修复流水线
agent·ai编程·claude
然我2 小时前
模型不是 Agent:从零实现一个最小 Agent Loop
前端·人工智能·agent
深蓝AI2 小时前
Mem0 实战:给 AI 应用加上长期记忆,从 Hello World 到生产用法
agent
AI效率君2 小时前
Deer‑Flow 2.0 + Go‑MCP‑Server(add加法工具)保姆级完整教程
人工智能·agent
昭昭日月明2 小时前
LangChain 生态:从链到代理,开发者需要掌握的三大核心
python·langchain·agent
Csvn2 小时前
第 12 章 并行化 Parallelization
人工智能·aigc·agent
苏灿烤鱼4 小时前
当 AI Agent 遇见真实科学环境:深度拆解 Scientific Agent Skills,把"聊天机器人"变成"AI 科学家"
python·开源·agent
Setsuna_F_Seiei13 小时前
前端转型 Agent 开发 03 之 Agent Tools - 给 Agent 装上手脚
前端·agent·ai编程