首页 / pi-agent 入门教程 / 事件系统:监听与响应

pi-agent 入门教程

事件系统:监听与响应

本教程共 30 篇 · 第 18 篇 · 更新于 2026-08-10 · 约 10 分钟阅读

pi-agent事件事件驱动Hook监听

本节目标:理解 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_startresources_discover初始化资源、加载配置
会话管理session_before_switchsession_shutdown保存状态、清理资源
用户输入input拦截命令、转换输入格式
Agent 周期agent_startturn_startcontextagent_end注入上下文、日志记录
工具调用tool_calltool_result权限控制、结果后处理
模型通信before_provider_requestafter_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_startsession_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 的转换会链式叠加。

完整示例:操作审计扩展

下面写一个综合示例,把几个核心事件串起来。这个扩展实现:

  1. 记录每次对话的模型和轮次
  2. 拦截 rm -rfsudo 做二次确认
  3. 给所有 read 输出加行号
  4. 在会话退出时写审计日志
// ~/.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

事件开发的几点提醒

  • 工厂函数只注册:不要在里面 setIntervalnew WebSocket()fs.watch()。资源创建放进 session_start,清理放进 session_shutdown
  • 关注 ctx.signal:在事件回调里做长时间操作时,检查 ctx.signal.aborted 来响应取消。
  • block 是终局性的tool_call 里返回 { block: true } 后,工具不会执行。后续 handler 也不会再被调用。
  • ctx.ui 跟用户交互confirmselectinputnotify ——这些方法让你在不打断流程的情况下获取用户反馈。

事件系统是 Extension 的核心能力。它把 pi 从一个黑盒变成了一个可编程的 Agent 框架。你不需要改 pi 的源码,只需要在正确的时机挂上正确的逻辑。