一句话定位
给 pi 加一个待办列表:LLM 通过 todo 工具增删改查,用户通过 /todos 命令查看------297 行,完整插件的四件套范本。
作用(为什么存在)
前两期拆的都是「单点能力」插件(拦截、快照),这期是完整插件:工具 + 命令 + 状态持久化 + 自定义渲染,四件套全齐。
它最值钱的地方是状态怎么存 :把状态写进工具结果的 details 字段,持久化到会话历史。这带来一个白送的能力------fork 后状态自动对齐分支,重启也不丢。你不需要自己维护任何文件或存储。
关键信息
| 项 | 内容 |
|---|---|
| 源码位置 | examples/extensions/todo.ts(297 行) |
| 核心 API | registerTool + registerCommand + ctx.ui.custom + ctx.sessionManager.getBranch() + renderCall/renderResult |
| 插件类型 | 工具 + 命令 + 状态 |
触发流程 / 数据流
css
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
架构 / 流程

关键代码解读
typescript
// 状态重建:从当前分支历史重放(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 文件 → 重扫不到 → 空列表
可借鉴的模式
- 状态持久化到会话历史 :借用工具结果
details把状态写进会话历史,状态跟会话走、分支对齐白送,免去单独维护存储。 - 完整快照而非增量 :每次返回整份
todos + nextId,让历史可重放------最后一份就是状态。增量记录做不到从历史重建。 - 清空重扫而非增量累加:内存可能过期(fork 切换后残留旧分支状态),直接丢弃内存、以历史重放为准。
- 双入口分工:工具管增删改查(唯一写入方),命令只读查看,避免多写入方竞争。
- 渲染分层 :
content给 LLM、details结构化、renderCall/renderResult管 TUI,展示层直接拿结构化数据。
一句话总结
297 行告诉你完整插件长什么样:工具给 LLM、命令给用户、状态持久化进会话历史------分支对齐和重启恢复都是白送的。