Pi 插件解剖|todo.ts:297 行,拼出工具+命令+状态的完整插件

0 阅读3分钟

一句话定位

给 pi 加一个待办列表:LLM 通过 todo 工具增删改查,用户通过 /todos 命令查看——297 行,完整插件的四件套范本。

作用(为什么存在)

前两期拆的都是「单点能力」插件(拦截、快照),这期是完整插件:工具 + 命令 + 状态持久化 + 自定义渲染,四件套全齐。

它最值钱的地方是状态怎么存:把状态写进工具结果的 details 字段,持久化到会话历史。这带来一个白送的能力——fork 后状态自动对齐分支,重启也不丢。你不需要自己维护任何文件或存储。

关键信息

内容
源码位置examples/extensions/todo.ts(297 行)
核心 APIregisterTool + registerCommand + ctx.ui.custom + ctx.sessionManager.getBranch() + renderCall/renderResult
插件类型工具 + 命令 + 状态

触发流程 / 数据流

LLM 调 todo 工具(add/toggle/clear/list)
  → execute 返回 content(给 LLM)+ details(完整状态快照:todos + nextId)
  → toolResult 消息 append 进 session.jsonl
  → session_start / session_tree 触发 reconstructState
      → getBranch() 遍历当前分支 → 筛 toolResult && toolName==="todo"
      → 最后一条 details 胜出 → 内存重建
用户 /todos → ctx.ui.custom 渲染 TodoListComponent

架构 / 流程

Tool Call Event Handling-2026-08-18-021919.png

关键代码解读

// 状态重建:从当前分支历史重放(session-manager 的 getBranch 返回分支 entries)
const reconstructState = (ctx) => {
  todos = [];          // ① 清空内存(可能已过期,不能信任)
  nextId = 1;
  for (const entry of ctx.sessionManager.getBranch()) {  // ② 遍历当前分支
    if (entry.type !== "message") continue;
    const msg = entry.message;
    if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;  // ③ 只筛本工具
    const details = msg.details;
    if (details) { todos = details.todos; nextId = details.nextId; }     // ④ 最后一条胜出
  }
};

// 工具返回:状态在 details 里(完整快照)
async execute(_toolCallId, params, ...) {
  // add 分支
  const newTodo = { id: nextId++, text: params.text, done: false };
  todos.push(newTodo);
  return {
    content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
    details: { action: "add", todos: [...todos], nextId },  // ← 完整状态快照
  };
}

亮点 / 踩坑

亮点 1:状态持久化到会话历史,分支对齐是白送的。 状态跟着工具调用走(每次返回完整快照),getBranch() 只返回当前分支的 entries,所以 fork 后重建出的就是那个分支的状态——不用自己处理"分支里状态该是什么"。

亮点 2:渲染分层。 content 给 LLM(纯文本语义)、details 存结构化数据、renderCall/renderResult 管 TUI 展示。展示层读 result.details 而不是解析 content 文本。

踩坑提示:跨机器会丢。 session 文件在 ~/.pi/agent/sessions/(全局目录),不在项目仓库——git 不跟踪、常规迁移不带。换机器后扫不到任何 todo 结果,状态变空。

边界(Limitations)

维度表现
fork/分支✅ 状态在会话历史,分支自动对齐
进程重启✅ 落盘 session,启动重建
跨机器❌ session 文件在全局目录,git 不跟踪、迁移不带
状态粒度每次完整快照、最后一条胜出;清空重扫防状态穿越

场景(Scenarios)

  • 能恢复:同机重启 / fork 到历史点 → 重放当前分支 → 重建对应状态
  • 静默丢失:换机器(session 目录空)/ 删 session 文件 → 重扫不到 → 空列表

可借鉴的模式

  1. 状态持久化到会话历史:借用工具结果 details 把状态写进会话历史,状态跟会话走、分支对齐白送,免去单独维护存储。
  2. 完整快照而非增量:每次返回整份 todos + nextId,让历史可重放——最后一份就是状态。增量记录做不到从历史重建。
  3. 清空重扫而非增量累加:内存可能过期(fork 切换后残留旧分支状态),直接丢弃内存、以历史重放为准。
  4. 双入口分工:工具管增删改查(唯一写入方),命令只读查看,避免多写入方竞争。
  5. 渲染分层content 给 LLM、details 结构化、renderCall/renderResult 管 TUI,展示层直接拿结构化数据。

一句话总结

297 行告诉你完整插件长什么样:工具给 LLM、命令给用户、状态持久化进会话历史——分支对齐和重启恢复都是白送的。