第 04 模块 · 3 节

生命周期与进阶用法

《Pi 进阶实战》04 扩展开发 Extensions · 本节时长 32 分钟

贯穿项目工具和命令会了,这一节学「让它们活得对」——事件顺序、状态恢复、/reload 热重载。你要让 md-tools 扩展能正确初始化、保存状态、方便调试。这是从「能跑」到「好用」的跨越。

生命周期:事件按什么顺序跑

官方给了一张完整的事件顺序图。关键主线:

pi 启动
 └─► project_trust
 └─► session_start
 └─► resources_discover
用户发提示词
 ├─► (扩展命令先检查)
 ├─► input(可拦截)
 ├─► before_agent_start(可注入/改系统提示词)
 ├─► agent_start
 ├─► 每个回合: turn_start → context → tool_call → tool_result → turn_end
 └─► agent_end → agent_settled
退出 → session_shutdown

两个容易混的点:

  • agent_start / agent_end:一次底层 agent 运行。但 Pi 可能还会自动重试、自动压缩后重试、处理排队的跟进消息。要看「确定不会再自动继续」,用 agent_settled
  • session_start 在会话「启动/加载/重载」时触发,带 reason(startup / reload / new / resume / fork)。

会话切换:session_shutdown → session_start

/new/resume/fork/clone 会触发会话替换,生命周期是:

  1. 先给旧实例发 session_shutdown(清理资源、保存状态)
  2. 为新会话重载并重绑扩展
  3. 再发 session_start(reason 为 new / resume / fork)

官方给的原则:在 session_shutdown 里做清理,在 session_start 里重建内存状态。对 md-tools,如果你维护了「最近处理的文件」这类状态,就在这两个事件里分别释放和重建。


状态管理:存到哪、怎么恢复

有状态的扩展(比如带 todo 的)需要注意分支支持。官方给了一个清晰模式——把状态存进工具结果的 details,在 session_start 里从会话历史重建:

export default function (pi: ExtensionAPI) {
  let items: string[] = [];

  pi.on("session_start", async (_event, ctx) => {
    items = [];
    for (const entry of ctx.sessionManager.getBranch()) {
      if (entry.type === "message" && entry.message.role === "toolResult") {
        if (entry.message.toolName === "my_tool") {
          items = entry.message.details?.items ?? [];
        }
      }
    }
  });

  pi.registerTool({
    name: "my_tool",
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      items.push("new item");
      return {
        content: [{ type: "text", text: "Added" }],
        details: { items: [...items] },  // 存起来供重建
      };
    },
  });
}

关键认知:内存里的变量(items)不会被持久化,靠的是把状态写进 details 再从会话里读回来。 这样分支切换时状态才能跟上。


/reload 热重载:改代码不用重启

官方给了 ctx.reload(),行为等同 /reload:发出 session_shutdown、重载资源、再发 session_start(reason "reload")。

注意几个要点

  • await ctx.reload() 之后,当前命令处理器仍在旧的调用栈里继续跑
  • await ctx.reload() 之后的代码,不能假设旧的扩展内存状态还有效
  • 为了可预期,把它当「终点」:await ctx.reload(); return;

所以调试扩展时,常用 ctx.reload() 让改动的代码生效,而不用整个重启 Pi。注意自动发现目录里的扩展支持热重载。


一处实用进阶:在 tool_call 里加安全拦截

前面 M04-1 提过权限门,这里给 md-tools 一个具体例子——拦截危险命令:

pi.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
    const ok = await ctx.ui.confirm("危险!", "允许执行 rm -rf 吗?");
    if (!ok) return { block: true, reason: "被用户拦截" };
  }
});

event.input 是可变的,能就地修改参数;返回 { block: true, reason } 可阻塞。这正是给 rename 这类不可逆操作加「确认层」的好位置。


落地练习:让 md-tools 扩展有状态且可热重载

  1. md_tools 工具加一个「最近处理文件」状态,存进 details,在 session_start 里重建
  2. ctx.reload()/reload 验证改动能热重载生效
  3. 加一个 tool_call 拦截,让 rename 走确认流程

怎么判断做对了?——重启/切分支后状态还在;改代码 /reload 后新逻辑生效;rename 会先弹确认。

卡住了怎么办? 状态丢 → 确认存进了 detailssession_start 正确重建。reload 无效 → 确认扩展在自动发现目录。拦截没触发 → 检查 event.toolNameevent.input.command 的取值。


常见坑:在 tool_call 里看「同一条消息的兄弟工具结果」

官方特别提醒:默认并行执行模式下,同一条助手消息里的兄弟工具调用,先顺序预检、再并发执行。tool_call 不保证能在 ctx.sessionManager 里看到同一条消息的兄弟工具结果。

简单说:别在 tool_call 里假设「这个助手消息里其他工具已经跑完了」。有这种依赖,就要重新设计,别在事件里赌执行顺序。


小结

  1. 事件顺序:session_start → input → before_agent_start → 各 turn → session_shutdown
  2. agent_settled 才表示「确定不会自动继续」
  3. 会话切换:session_shutdown 清理、session_start 重建状态
  4. 状态存 details,从会话历史恢复,内存变量不持久
  5. ctx.reload() 热重载,别在之后依赖旧内存状态

下一节进入 M05 贯穿项目——把 md-tools 完整封装成 Skill。