事件系统:监听与响应
本教程共 30 篇 · 第 18 篇 · 更新于 2026-08-10 · 约 10 分钟阅读
本节目标:理解 pi 的事件驱动模型,掌握核心事件的触发时机和监听方式,能用事件钩子实现日志记录、危险操作拦截、结果后处理等实用功能。
pi 运行过程中,每个关键节点都会发出事件。用户发消息、AI 开始思考、工具被调用、会话切换——每一步都有对应的事件。Extension 通过监听这些事件,在正确的时机插入逻辑。
这就是事件驱动架构。你不必修改 pi 的源码,只需在事件上注册回调,pi 会在合适的时机调你。
生命周期全景
从启动到退出,pi 的事件线长这样。别被图吓到——大部分事件日常开发用不上,后面会挑最常用的细讲。
pi 启动
│
├─► project_trust (决定是否信任项目,仅全局/CLI 扩展可参与)
├─► session_start (会话启动,reason: "startup")
└─► resources_discover (资源发现,可注入 skill/theme 路径)
│
▼
用户发送提示 ────────────────────────────┐
│ │
├─► input (可拦截或转换用户输入)
├─► before_agent_start (可注入消息、修改 system prompt)
├─► agent_start (Agent 开始处理)
│ │
│ ┌─── 每个轮次 (turn) ──────┐ │
│ │ │ │
│ ├─► turn_start │ │
│ ├─► context (可修改发给 LLM 的消息)
│ ├─► before_provider_headers │ │
│ ├─► before_provider_request │ │
│ ├─► after_provider_response │ │
│ │ │ │
│ │ LLM 决定调用工具: │ │
│ │ ├─► tool_execution_start │
│ │ ├─► tool_call (可阻止) │
│ │ ├─► tool_execution_update │
│ │ ├─► tool_result (可修改) │
│ │ └─► tool_execution_end │
│ │ │ │
│ └─► turn_end │ │
│ │
├─► agent_end │
└─► agent_settled (不再有重试/压缩/后续)│
│
用户发送另一条提示 ◄──────────────────────┘
退出 (Ctrl+C / Ctrl+D / SIGHUP)
└─► session_shutdown
事件按阶段分组,方便记忆:
| 阶段 | 事件 | 常见用途 |
|---|---|---|
| 启动 | session_start、resources_discover | 初始化资源、加载配置 |
| 会话管理 | session_before_switch、session_shutdown | 保存状态、清理资源 |
| 用户输入 | input | 拦截命令、转换输入格式 |
| Agent 周期 | agent_start、turn_start、context、agent_end | 注入上下文、日志记录 |
| 工具调用 | tool_call、tool_result | 权限控制、结果后处理 |
| 模型通信 | before_provider_request、after_provider_response | 请求审计、用量统计 |
核心事件详解
session_start —— 资源初始化的最佳时机
会话启动时触发,是初始化扩展状态和后台资源的正确位置。
pi.on("session_start", async (event, ctx) => {
// event.reason: "startup" | "reload" | "new" | "resume" | "fork"
// event.previousSessionFile: 前一个会话文件(resume/fork 时有值)
ctx.ui.notify(
`会话已启动(${event.reason})`,
"info"
);
});
Tip不要在工厂函数里启动后台资源(WebSocket、定时器、文件监听器)。工厂函数只是注册,资源初始化和清理应该放在
session_start和session_shutdown这对事件里。
input —— 在 AI 看到之前拦截输入
用户输入被处理前触发。你可以在这里转换内容,或者直接接管整个请求。
pi.on("input", async (event, ctx) => {
// event.text - 原始输入
// event.images - 附带图片
// event.source - "interactive" | "rpc" | "extension"
// 转换:在快捷指令前加前缀
if (event.text.startsWith("?quick ")) {
return {
action: "transform",
text: `简短回复,不要解释:${event.text.slice(7)}`,
};
}
// 直接处理:不经过 LLM
if (event.text === "ping") {
ctx.ui.notify("pong", "info");
return { action: "handled" };
}
// 默认:正常处理
return { action: "continue" };
});
返回的 action 决定输入怎么走:
| action | 效果 |
|---|---|
"continue" | 原样继续(默认) |
"transform" | 用返回的 text 替换原始输入,多个 handler 链式叠加 |
"handled" | 完全接管,跳过 LLM 调用。第一个返回此值的 handler 生效 |
Note
input事件在 Skill 和模板展开之前触发。此时你拿到的是用户敲的原始文本,/skill:foo这种还没展开。
tool_call —— 危险操作的守门人
工具执行前触发。可以修改参数,也可以直接阻止。
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
// 用 isToolCallEventType 做类型收窄,获得对应工具的 input 类型
if (isToolCallEventType("bash", event)) {
// event.input 类型为 { command: string; timeout?: number }
// 自动注入 source ~/.profile
event.input.command = `source ~/.profile\n${event.input.command}`;
// 拦截危险命令
const dangerous = ["rm -rf /", "mkfs.", "dd if=", "> /dev/sda"];
if (dangerous.some(cmd => event.input.command.includes(cmd))) {
return { block: true, reason: "危险命令已被阻止" };
}
}
// 记录所有文件读取
if (isToolCallEventType("read", event)) {
// event.input 类型为 { path: string; offset?: number; limit?: number }
console.log(`[audit] 读取文件:${event.input.path}`);
}
});
event.input 是可变的——你在回调里改什么,工具执行时就用什么。
tool_result —— 给 LLM 的结果加料
工具执行完后触发。可以修改返回给 LLM 的内容。典型场景:给 read 的输出自动加行号。
pi.on("tool_result", async (event, ctx) => {
// event.toolName - 工具名
// event.content - 返回给 LLM 的内容数组
// event.details - 结构化详情
// event.isError - 是否出错
if (event.toolName === "read" && !event.isError) {
// 取所有文本内容,给每行加行号
const text = event.content
.filter(c => c.type === "text")
.map(c => c.text)
.join("\n");
const numbered = text
.split("\n")
.map((line, i) => `${i + 1}: ${line}`)
.join("\n");
return {
content: [{ type: "text", text: numbered }],
};
}
});
context —— 在发出去之前修改消息
每次 LLM 调用前触发,收到的 event.messages 是当前上下文消息的深拷贝。你可以随意改它。
pi.on("context", async (event, ctx) => {
// event.messages - 深拷贝,安全修改
// 过滤掉自定义类型为 "debug" 的消息
const filtered = event.messages.filter(m => {
if (m.type === "custom" && m.customType === "debug") {
return false;
}
return true;
});
return { messages: filtered };
});
before_agent_start —— 注入系统级上下文
Agent 开始处理用户输入之前触发。可以注入额外的消息记录或修改系统提示词。
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt - 用户提示文本
// event.systemPrompt - 当前系统提示词
return {
// 注入一条持久化消息(存入会话,发给 LLM)
message: {
customType: "current-time",
content: `当前时间:${new Date().toISOString()}`,
display: true,
},
// 追加到系统提示词
systemPrompt: event.systemPrompt + "\n\n请在所有回复中使用中文。",
};
});
session_shutdown —— 优雅退出
会话结束或切换时触发。在这里清理 session_start 中创建的定时器、连接等资源。
pi.on("session_shutdown", async (_event, _ctx) => {
// 关闭数据库连接
// 清除定时器
// 写入最终状态
console.log("会话关闭,资源已清理");
});
多个扩展监听同一事件
同一个事件可以有多个扩展同时监听,按加载顺序依次执行。
对于 tool_call 类的拦截事件,用 { block: true } 阻止后续处理。对于 input 类的 transform 事件,多个 handler 的转换会链式叠加。
完整示例:操作审计扩展
下面写一个综合示例,把几个核心事件串起来。这个扩展实现:
- 记录每次对话的模型和轮次
- 拦截
rm -rf和sudo做二次确认 - 给所有
read输出加行号 - 在会话退出时写审计日志
// ~/.pi/agent/extensions/audit-guard.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
import { appendFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
let turnCount = 0;
let modelName = "unknown";
export default function (pi: ExtensionAPI) {
// === 初始化 ===
pi.on("session_start", async (event, ctx) => {
turnCount = 0;
modelName = "unknown";
ctx.ui.notify("🔒 审计扩展已激活", "info");
});
// === 记录 agent 信息 ===
pi.on("agent_start", async (_event, _ctx) => {
modelName = "(启动中)";
});
pi.on("turn_start", async (_event, _ctx) => {
turnCount++;
});
// === 拦截危险 bash 命令 ===
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return;
// 自动注入环境
event.input.command = `source ~/.profile 2>/dev/null\n${event.input.command}`;
// 危险命令二次确认
if (event.input.command.includes("rm -rf") || event.input.command.includes("sudo")) {
const ok = await ctx.ui.confirm(
"⚠️ 危险操作",
`确认执行:${event.input.command.slice(0, 80)}?`
);
if (!ok) {
return { block: true, reason: "用户取消危险操作" };
}
}
});
// === read 结果加行号 ===
pi.on("tool_result", async (event, _ctx) => {
if (event.toolName !== "read" || event.isError) return;
const text = event.content
.filter(c => c.type === "text")
.map(c => c.text)
.join("\n");
const numbered = text
.split("\n")
.map((line, i) => `${String(i + 1).padStart(4, " ")}│ ${line}`)
.join("\n");
return { content: [{ type: "text", text: numbered }] };
});
// === 退出时写审计日志 ===
pi.on("session_shutdown", async (_event, _ctx) => {
const logDir = join(homedir(), ".pi", "logs");
await mkdir(logDir, { recursive: true });
const logLine =
`[${new Date().toISOString()}] 会话结束`
+ ` | 模型:${modelName}`
+ ` | 总轮次:${turnCount}\n`;
await appendFile(join(logDir, "audit.log"), logLine, "utf8");
});
}
测试:
pi -e ~/.pi/agent/extensions/audit-guard.ts
试试让 AI 执行 rm -rf,看会不会弹出确认框。试试让它 read 一个文件,看输出是不是带行号了。退出后检查 ~/.pi/logs/audit.log。
Tip这个扩展里的
isToolCallEventType("bash", event)不仅做了类型守卫,还给event.input提供了精确的 TypeScript 类型。写 tool_call 监听器时强烈建议用这个函数,而不是自己判断event.toolName。
事件开发的几点提醒
- 工厂函数只注册:不要在里面
setInterval、new WebSocket()或fs.watch()。资源创建放进session_start,清理放进session_shutdown。 - 关注
ctx.signal:在事件回调里做长时间操作时,检查ctx.signal.aborted来响应取消。 block是终局性的:tool_call里返回{ block: true }后,工具不会执行。后续 handler 也不会再被调用。- 用
ctx.ui跟用户交互:confirm、select、input、notify——这些方法让你在不打断流程的情况下获取用户反馈。
事件系统是 Extension 的核心能力。它把 pi 从一个黑盒变成了一个可编程的 Agent 框架。你不需要改 pi 的源码,只需要在正确的时机挂上正确的逻辑。