和 AI 一起修 Bug:一次 pi plan-mode 扩展的协作调试记录
一、开场
最近在用 pi(终端编码助手)的 plan-mode 扩展做开发。plan-mode 是 Claude Code 风格的只读探索模式:开启后 agent 只能读文件、查代码、不能修改,适合先分析再动手的工作流。
大概长这样:
/plan 开启 → 问 LLM 一个问题
→ LLM 给出分析,并创建一个编号计划
→ 弹出对话框让你选:
1. 执行计划
2. 再想一个方案
3. 微调计划
用起来很顺手,直到我选了 “Refine the plan”——
二、Bug 现场
弹了个错误通知:
Extension "<runtime>" error: Agent is already processing.
Specify streamingBehavior ('steer' or 'followUp') to queue the message.
叫来我的 Agent(也就是这篇博客的另一位作者)帮忙修。
第一反应:“缺参数吧?加上 { deliverAs: "steer" } 试试。”
Agent 改了一行,不报错了。搞定?
并没有。故事才刚开始。
三、三次错误方向——人类直觉 vs 源码真相
方向一:选 Stay 就是放弃计划?
“Stay in plan mode”——字面理解:待在 plan 模式里。
但用户问了一个问题:“放弃了计划还在 plan 模式里?矛盾。”
确实是。Agent 在 Stay 的处理里加了 todoItems = [] 清空计划,但清空也没用——下一轮 LLM 又生成了新计划,agent_end 重新提取,对话框又弹了出来。
用户还是逃不出去。
方向二:加个标志不再弹对话框
Agent 的结论:“Stay 之后不要再弹对话框了,加个标志跳过。”
加了 planPrompted 变量,选了 Stay 后就设为 true,下一轮 agent_end 跳过提取和对话框。
用户测试后说:“选了 Stay 还是一样弹出对话框。”
排查了半天才发现——扩展在 pi 启动时加载到内存,修改 .ts 文件后需要 /reload 才会生效。 Agent 改的是磁盘上的文件,但 pi 跑的还是旧代码。
这算是我跟 AI 协作的一个教训:它改代码,我 reload,这个流程双方都需要记住。
方向三:过度设计
Agent 又加了一堆改动:
- 改
before_agent_start,让 Stay 之后 LLM 不再生成新计划 - 给
/todos加了执行选项 - 改了 session 恢复逻辑
从 1 行改动膨胀到了 50 多行,越改越复杂。
这时候,Agent 做了一件对的事:
“先看看官方 examples 的原始代码吧。“
四、转折——读源码
打开 pi 官方 examples 中的 plan-mode 源码。
继续翻 CHANGELOG,原作者 @ferologics 的原始描述是:
“Interactive prompt after each response: execute plan, stay in plan mode, or refine”
每个单词都很关键:
- after each response — 每次回复后都弹对话框,不是只弹一次
- stay in plan mode — 只是”停留在 plan 模式”,不是”放弃计划不再弹出”
这完全推翻了我之前的理解。
我又翻了 PR 记录,这个扩展的作者是社区贡献者,不是 pi 核心团队。它的设计模仿了 Claude Code 的 plan mode——一个迭代流程:
提案 → 不满意(Stay)
→ 微调(Refine)
→ 执行(Execute)
↺ 不满意再提案,直到满意
每次回复后弹出对话框是刻意设计的,不是 bug。
五、三个选项的真正语义
选项
我之前的理解(❌)
源码中的真实语义(✅)
Execute
执行计划
执行当前计划
Stay
放弃计划,不再弹出
再想一个方案,下一轮继续弹
Refine
微调计划
微调计划,修改后再次弹出
Stay 不是”不弹了”,而是”这个方案不行,换个思路”。
“Stay in plan mode” 这个文案确实有歧义——它听起来像”保留当前状态不动”,但实际上它的语义是 “搁置当前提案,继续 brainstorm”。
六、最终产出——三处改动
理解了原设计后,修复方案变得非常清晰:
改动 1:修 Bug(1 行)
- pi.sendUserMessage(refinement.trim());
+ pi.sendUserMessage(refinement.trim(), { deliverAs: "steer" });
这是唯一的”真 bug”——sendUserMessage 在 agent_end 事件监听器中被调用时,agent 仍处于 streaming 状态(isStreaming = true),必须显式指定 streamingBehavior 参数。
为什么 “Execute the plan” 没事?因为它用的是 sendMessage,底层 sendCustomMessage 在 streaming 时会默认调用 steer()。而 sendUserMessage 则强制要求参数。这是框架 API 的一个不对称设计。
改动 2:消歧义(2 处文字替换)
- "Stay in plan mode"
+ "Another plan"
“Stay in plan mode” 听起来像”不动”,“Another plan” 准确表达了”再想一个”。
改动 3:加逃生门(1 个选项 + Escape 处理)
原设计假设用户总会在三个选项里选一个,但如果三个都不想要呢?
在对话框里加了第四个选项:
Execute the plan (track progress)
Another plan
Refine the plan
Exit plan mode ← 新增
按 Escape 同样退出。
退出时还会注入一条隐藏消息 [PLAN ABANDONED],告诉 LLM 旧计划已废弃,避免后续对话中 LLM 继续执行旧计划的步骤。
七、技术拾遗
记录几个在调试过程中学到的东西。
agent_end 与 isStreaming
runWithLifecycle:
isStreaming = true
→ 运行 agent 循环
→ 发出 agent_end 事件
→ 等待所有监听器完成 ← 你的代码在这里执行!
→ finishRun() → isStreaming = false
在 agent_end 的监听器中,isStreaming 仍为 true。这意味着你不能在这个阶段调用 prompt() 启动一个新的 agent 运行。
sendMessage vs sendUserMessage
方法
streaming 时行为
是否报错
sendMessage
默认调用 steer()
✅ 安全
sendUserMessage
强制要求 deliverAs 参数
❌ 缺参数就抛错
这是框架 API 的不对称之处——文档中似乎没有明确说明这个差异。
扩展热重载
扩展在 pi 启动时加载到内存,修改 .ts 文件后需要 /reload 才能生效。这个机制简单但容易忘记。
八、一些体会
1. 和 AI 协作 Debug 的节奏
这次调试的过程很有意思——我说直觉方向,Agent 执行代码修改;Agent 发现源码里的矛盾点,我去阅读确认。双方互相补充。
但也暴露了一个问题:AI 倾向于快速加代码,而不是先读代码。 如果一开始就读官方的 examples,50 行改动可能变成 2 行。
2. 改代码前先读源码
三次错误方向的根本原因是一样的:没有先理解原设计者的意图。
每次我说”我觉得这里应该这样改”,Agent 就动手改了。但我们从没停下来看看官方源码是怎么写的。如果能重来一次:
- 报错 → 定位到
sendUserMessage缺参数 → 修 bug(1 行) - 疑惑”Stay in plan mode 是什么意思” → 读源码 → 理解设计
- 根据理解决定是否改文案、是否加功能
而不是一边改一边猜。
3. 最小改动原则
最终修复的内容:
- 修 bug:1 行
- 消歧义:2 处文字替换
- 加功能:1 个对话框选项
三处改动,各自独立,彼此不纠缠。做最少的事,比做你认为对的事,更安全。
4. 原设计一般都有道理
“每次回复后都弹对话框,不能 Escape 退出”——这看起来像个缺陷,但它是刻意设计的。
它的假设是:你进入 plan mode 就是为了在三个选项里选一个。 如果三个都不想要,关闭 plan mode 的方式是 /plan 命令,而不是从对话框里退出。
这个假设对大多数使用场景是成立的。但确实存在”三个都不想要”的边缘情况——所以我们加了第四个选项,而不是改原来的三个。
附录
最终代码 diff
只展示关键改动部分:
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.editor("Refine the plan:", "");
if (refinement?.trim()) {
- pi.sendUserMessage(refinement.trim());
+ pi.sendUserMessage(refinement.trim(), { deliverAs: "steer" });
}
} else if (choice === "Exit plan mode" || !choice) {
togglePlanMode(ctx);
+ pi.sendMessage({
+ customType: "plan-abandoned",
+ content: "[PLAN ABANDONED] The previous plan has been abandoned by the user. Do not execute any of its steps.",
+ display: false,
+ }, { triggerTurn: false });
}
(文案替换 "Stay in plan mode" → "Another plan" 和新增 "Exit plan mode" 选项的改动比较简单,不单独列出。)