一、引言: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.ts,export 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-guard(examples/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 文件必须:
-
export default function(pi: ExtensionAPI):default export 是一个函数,接收ExtensionAPI实例。pi 加载扩展时调这个函数,函数里通过pi注册事件、工具、命令等。 -
只做注册,不做长时间运行 :default export 函数应该快速返回。如果需要长期监听(如 file-trigger 的
fs.watch),在on("session_start")handler 里启动监听,不要在 default export 里阻塞。 -
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)------发消息给 agentpi.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.ts(coding-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}` };
}
}
四步:
loadExtensionModule------动态 import.ts文件,拿到 default export。这是加载的核心(下方详讲)。createExtension------创建一个空的 Extension 对象,内含空的handlers/tools/commands/flags/shortcuts等 Map。createExtensionAPI------创建ExtensionAPI实例,把 extension 对象、runtime、eventBus、cwd 绑定在一起。pi.on(...)/pi.registerTool(...)等方法的实现就是往 extension 的 Map 里塞东西。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 什么时候被调用?谁负责调度?这靠 ExtensionRunner(runner.ts,1092 行)。
Extension API 的核心组件关系:
AgentSession持有ExtensionRunner,通过beforeToolCall/afterToolCall委托给它ExtensionRunner持有Extension[],按事件类型从各自的handlersMap 里取 handler 执行ExtensionRunner.createContext()生成ExtensionContext,作为 handler 的第二参数传入- 每个
Extension是一个注册容器------handlers / tools / commands / flags / shortcuts / messageRenderers 六个 Map
1. hasHandlers + emit:分派机制
文章 5 讲过,AgentSession._installAgentToolHooks 把 beforeToolCall / 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 的 hasHandlers 和 emitToolCall 是这么实现的:
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" });
}
四步:
session_shutdown------给所有扩展的session_shutdownhandler 一个清理机会(关文件监听、释放资源)- reload ------
resourceLoader.reload()调loadExtensions重新扫描.pi/extensions/,用jiti的moduleCache: false重新编译 - 重建 runtime ------创建新的
ExtensionRunner,绑定新的 extension 列表 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 核心接口
ExtensionAPI(types.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-trigger(examples/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. 调用链流程
完整路径分两段:
加载段(agent 启动时):
loader.loadExtension用 jiti 编译file-trigger.ts,拿到 factory 函数factory(pi)执行------pi.on("session_start", handler)把 handler 存入 extension.handlers- runner 绑定 extensions
session_start事件触发------runner 从 handlers Map 里取出 handler 执行- handler 里
fs.watch启动文件监听
运行段(外部触发时):
- 外部系统写文件 →
fs.watch回调触发 - 读文件内容 →
pi.sendMessage注入消息 +triggerTurn: true sendMessage内部调AgentSession的消息注入机制------走文章 5 讲的 steering / followUp 队列triggerTurn: true让 agent loop 开始新一轮------LLM 看到 "External trigger: Run tests"- LLM 按消息内容执行任务(如调 bash 跑测试)
- 清空触发文件
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 真实场景里最重要的应用。