🚀 欢迎来到「不用框架,手搓 AI Agent」系列第六篇。没读过前文也没关系,你只需要知道:我们已经有了一个能读文件、改代码、执行命令的最小 AI Agent。
你有没有遇到过这种情况:明明已经特意告诉 AI Agent:
“先别改代码,先把项目理解清楚。”
结果它读了两三个文件,转头就调用 edit_file 开始改了。
我们都说了“先别改”,它怎么还是动手了?
这并不是我们手搓的 Agent 才会遇到的问题。
只要一个 Coding Agent 同时拥有读取项目、修改文件和执行命令的能力,它就必须判断:什么时候还在了解项目,什么时候可以开始动手。
如果 Harness 一开始就把 read_file、edit_file 和 bash 这些工具全部交给模型,那么模型读完几个相关文件后,很可能认为信息已经足够,接着选择最能推进任务的动作——直接修改代码。
Claude Code、Codex、Cursor 这类成熟的编程 Agent,同样需要处理这个问题。因此,它们都提供了 Plan Mode 或类似的规划流程。
具体实现虽然不完全相同,但核心思路很接近:先读取和搜索项目,形成实施计划,暂停并等待用户确认,批准后再修改代码。
Plan Mode 要解决的,正是“什么时候只能调查,什么时候可以开始执行”这个能力边界问题。
回到我们自己的 Agent。
在前面的章节里,我们已经让它学会了读取文件、搜索项目、修改代码和执行命令,也实现了能够持续调用工具的 Agent Loop。
但目前所有工具从一开始就是可用的,整个循环里也没有“规划阶段”“等待审批”和“执行阶段”的区别。
所以用户说的“先别改,先看看”,目前仍然只是一句提示词,而不是 Harness 真正执行的限制。
这一篇,我们就来补上这块缺失的能力:给 Agent 加入真正的 Plan Mode 和任务清单。它会先在只读状态下调查项目、提交计划并等待审批;用户批准后,再恢复修改文件和执行命令的能力,并通过任务清单持续记录执行进度。
先看看这个系列最终会做出什么
上面演示的,不是只完成这一篇后的 Agent,而是经过整个系列逐步打磨后,我们最终会亲手做出的“完全体”。
它会具备一个编程 Agent 应有的大部分核心能力:理解项目、规划任务、等待审批、修改代码、调用工具,并持续跟踪执行进度。
这一篇不会一次做完全部功能,我们会先聚焦完全体中的两块能力:Plan Mode 和 任务清单。
系列目录
- 不用框架,手搓 AI Agent:(一)先让它跑起来
- 不用 LangChain,手搓 AI Agent:给大模型装上“手”,让它自己读项目文件
- 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
- Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
- 不用 LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
- 本文:我让 AI Agent 先别改代码,它怎么还是动手了?
- 更多实战持续更新中……
🚀 本节配套源码:powercode 👈 点它
如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐
为了不让大段完整源码打断阅读,正文只保留用来解释原理的关键片段。需要复制完整文件时,可以直接点击每一步后面的 GitHub 源码链接。
一个很典型的翻车现场
上周五,隔壁同事探过头来:
“登录这块能不能顺手改一下?现在新旧两套接口看着有点乱,最好再补几个测试。”
“行,我先让 Agent 看看,应该挺快。”
你把需求复制给 Agent,然后起身接了杯水。等你回来,它已经开始干活了:
read_file
src/login.ts
read_file
src/routes.ts
edit_file
src/login.ts
edit_file
src/...
没过多久,Agent 就回复:
“登录模块已经重构完成。”
同事扫了一眼改动,马上发现了问题:
“等一下,这个文件早就不用了。线上走的是另外一层,而且那个旧接口不能删,客户端还在调。”
你赶紧往下翻,越看越不对:
真正的登录入口还没读
旧接口有哪些调用方也没搜全
兼容逻辑被当成旧代码删了
测试命令一次都没跑
问题不在于 Agent 不会写代码,而在于信息还没查完整时,我们就已经把 write_file、edit_file 和 bash 都交给了它。
你说的“先看看”,是想让它先把项目摸清楚;但嘴上说“先看看”,并不等于 Harness 真的限制它只能看。
如果是把任务交给同事,我们多半会补一句:
“你先别改。把入口、调用方、兼容方案、可能踩的坑,还有准备怎么测都列一下。我看完没问题,你再动手。”
这其实就是 Plan Mode 要解决的问题。
在 Claude Code、Codex、Cursor 这类成熟的编程 Agent 产品中,都可以看到类似的规划模式。具体实现不尽相同,但核心思路很接近:规划时保留读取和搜索能力,暂时收起会修改项目的工具;等计划通过审批,再恢复写文件和执行命令的能力。
提示词里的“先别改”只是一句建议,Plan Mode 才是由 Harness 真正执行的能力边界。
那么今天,我们就来给自己的 Agent 实现一个 Plan Mode。
Plan Mode 到底是什么
Plan Mode 可以理解成 Agent 的“只做方案,暂不施工”阶段。
我们先以 Claude Code 为例。
在可以执行修改的模式下,Claude Code 的目标是把任务直接做完:
切换到 Plan Mode 之后,Claude Code 会暂时收紧修改源码的能力,先读取和搜索项目、形成计划,然后等待用户审批。
这时它的目标不是完成代码,而是先回答:
真正需要改什么
涉及哪些文件和调用关系
准备按什么顺序实现
有哪些风险
最后怎样验证
因此,进入 Plan Mode 后,Agent 应该经历三个阶段:
所以 Plan Mode 不是另一种大模型,也不只是生成一个 PLAN.md。
它首先是 Harness 中的一种运行状态:
计划内容由大模型生成,但“批准前不能修改”和“提交后必须等待”应该由 Harness 保证。
为什么 Agent 总想马上改代码
这里先澄清一下:大模型不是真的“性格冲动”。
它只是会根据当前目标、上下文和可用工具,选择一个看起来最能推进任务的动作。
当用户说:
帮我重构登录模块
Harness 又同时提供以下工具:
read_file
write_file
edit_file
bash
那么从模型的角度看,读取和修改都是合法动作。它读到一两个相关文件后,很容易判断“信息已经够了”,下一步自然就是调用 edit_file。
Agent Loop 还会继续推动它向前:
整个循环里,没有任何一步要求它:
先证明自己已经找全入口
先列出影响范围
先把方案交给用户
等用户批准后才能继续
所以它不是故意乱来,而是在我们提供的 Harness 里,“直接开改”本来就是一条畅通无阻的路径。
即使在提示词里加一句:
请先了解清楚,再修改代码。
模型仍然需要自己判断什么叫“了解清楚”。它可能读完两个文件,就认为这个条件已经满足了。
所以Plan Mode 做的第一件事,就是从能力层把这条路先堵住:
Plan Mode 可用
├── read_file
├── list_files
├── search_files
└── submit_plan
Plan Mode 不可用
├── write_file
├── edit_file
└── bash
这时即使模型觉得“可以开始改了”,它也没有修改文件的工具。它能做的只有继续读取、继续搜索,或者调用 submit_plan 把方案交出来。
不过,收走写工具只解决了“不能提前修改”。
要让它真的形成“先了解,再确认”的流程,还需要另外两层:
规划提示词
要求先探索项目,计划中写清步骤、风险和验证方式
审批状态机
submit_plan 后暂停 Agent Loop,必须等待用户选择
所以这一篇实现的并不是一个工具开关,而是三层配合:
这个骨架并不是我们凭空发明的。Claude Code、Cursor、Codex 和 Oh My Pi 的 Plan Mode,都能看到类似的“先规划、再审批、后执行”思路,只是每家把它落在了不同的地方:
| 产品 | 和我们相似的地方 | 主要差异 |
|---|---|---|
| Claude Code | Plan Mode 会先调查项目、提交方案,批准前不修改源码 | 它把 Plan 直接做成了权限模式,并提供了多种批准后的执行方式 |
| Cursor | 先搜索代码库、询问关键问题、生成计划,然后等待用户批准 | 它的计划可以作为 Markdown 直接编辑和保存 |
| Codex | 同样强调先探索真实项目,再输出可执行的完整方案 | 它更像一种规划协作协议;公开资料并没有说内部也使用名为 submit_plan 的工具 |
| Oh My Pi | 有独立的 Plan 角色、计划产物和批准入口 | 还支持批准后清空、压缩或保留规划上下文,比我们的第一版复杂得多 |
| Pi 原版 | 可以通过扩展实现这套流程 | 核心本身明确没有内置 Plan Mode,不能说它默认就是这样实现的 |
因此,我们这一版更准确的说法是:
用 Claude Code 式的权限边界和审批流程做骨架,再加入 Codex 式的规划提示词。
我们自己定义的 submit_plan,可以理解为一个明确的“规划完成”事件。它的作用和其他 Agent 里的“退出 Plan Mode”或“提交计划”很像,但不代表这些产品内部都使用了同名工具。
我们希望 Agent 也遵守同样的流程:
最终的终端体验会是这样:
> /plan
已进入 Plan Mode,只能读取和搜索项目。
> 重构登录模块,兼容旧接口并补上测试
Agent 读取目录、搜索调用关系、分析风险……
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
> 1
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-重构登录模块/
批准之前,Agent 看不到写文件和执行命令的工具;批准之后,它才开始修改代码,并用 TODO 记录自己做到哪一步。
这就是这一篇要实现的 Plan Mode:
它不是一句“请先想清楚”的提示词,而是一套由 Harness 控制的“只读—审批—执行”状态机。
这一篇要实现什么
基于第五篇完成后的 powercode,我们增加六项能力:
code、plan两种模式;/plan、/code、/status三个命令;- Plan Mode 只能读取、列目录和搜索;
- 模型必须通过
submit_plan提交计划标题、完整正文和执行步骤; - Agent Loop 提交计划后暂停,等待用户审批;
- 批准后进入 Code Mode,在
.powercode/tasks/<plan-name>/中生成PLAN.md和TODO.md再执行。
最后的工具边界是:
Plan Mode
├── read_file # 读取指定文件的内容
├── list_files # 查看项目目录结构
├── search_files # 在项目中搜索文本和代码
└── submit_plan # 提交完整计划并进入审批阶段
Code Mode
├── read_file # 读取指定文件的内容
├── list_files # 查看项目目录结构
├── search_files # 在项目中搜索文本和代码
├── write_file # 创建文件或写入完整内容
├── edit_file # 对已有文件进行定向修改
└── bash # 执行构建、测试等终端命令
为什么 Plan Mode 不提供 Bash?
不是因为 Bash 完全不能用,而是这个 Demo 的规划阶段根本不需要它。
Plan Mode 只需要完成三件事:
read_file
只读取一个文件
list_files
只列出目录结构
search_files
只搜索文本
这三个工具已经足够让 Agent 看清目录结构、找到相关代码并读取文件内容。
如果再把 Bash 交给 Plan Mode,我们就还要判断每条 Shell 命令到底只是查询,还是可能写文件、删文件或访问网络。这会引入很多和本文主线无关的边界处理。
像 Codex 和 Claude Code 这类成熟产品,可以继续通过命令解析、权限规则、用户审批和沙箱来约束 Bash。但对我们的第一版来说,最清楚的选择就是:
Plan Mode 只提供规划真正需要的专用查询工具,暂时不提供 Bash。
先说明仓库和目录
本文继续使用主项目 powercode。
第五篇结束后,我们已经把当时的代码冻结在:
powercode/demos/05-session-compaction
这个目录是第五篇的独立 Demo,用来回看 Session 和 Compactor,不再继续修改。
第六篇要改的是主项目源码:
powercode/
├── provider.json
├── src/ # 本文继续开发的 Agent 源码
│ ├── agent.ts
│ ├── main.ts
│ ├── plan.ts # 本文新增
│ ├── plan-files.ts # 本文新增
│ ├── options.ts # 本文新增
│ ├── context/
│ │ ├── session.ts
│ │ └── compactor.ts
│ └── tools/
│ ├── read-file.ts
│ ├── write-file.ts
│ ├── edit-file.ts
│ ├── bash.ts
│ ├── list-files.ts # 本文新增
│ ├── search-files.ts # 本文新增
│ └── submit-plan.ts # 本文新增
├── demos/
│ └── 05-session-compaction/ # 第五篇完成态,不再修改
└── workspace/
└── task-board/ # 交给 Agent 规划和执行的练习目录
└── .powercode/ # 运行 Plan 后生成的任务记录
└── tasks/
└── <plan-name>/
├── PLAN.md
└── TODO.md
三个目录不要混在一起:
powercode/src
我们正在开发的 Agent Harness
powercode/demos/05-session-compaction
第五篇的冻结版本
powercode/workspace/task-board
Agent 实际读取、搜索和修改的练习项目
后面的代码都基于第五篇完成后的主项目继续添加。没有读过前文也没关系:本文会讲清每个关键修改,完整文件则放在 GitHub 中供你直接对照。
先说结论:Plan 和 TODO 是两个概念,但可以放在同一份文档
Plan 和 TODO 经常一起出现,但它们解决的不是同一个问题。
Plan
决定准备怎么做
发生在执行之前
需要用户审核
TODO
记录现在做到哪一步
发生在计划批准之后
随执行进度更新
有些 Agent 会把两者放在同一份文档里。例如 Codex 生成的计划文档中,就可能直接出现这样的勾选清单:
- [ ] 找到登录模块的真实入口
- [ ] 梳理旧接口的调用方
- [ ] 实现兼容层
- [ ] 补充测试并验证
在计划还没有批准时,这些勾选项表示的是“准备怎么做”,本质上仍然是 Plan 的任务分解。如果 Agent 在批准后继续更新勾选状态,这份文档就同时承担了 TODO 的职责。
所以真正需要分开的是两种状态:
批准前
这是待审核的计划清单
批准后
这是正在执行的进度清单
至于物理上是一个文件还是两个文件,属于 Harness 的实现选择。这一篇为了让职责更清楚,会为每次批准的计划创建一个独立任务目录,再在里面保存两份文件:
.powercode/tasks/<plan-name>/
├── PLAN.md
│ 保存用户批准的方案,尽量保持稳定
└── TODO.md
从计划步骤生成,执行时持续更新
这样不同任务的计划和进度不会互相覆盖,以后也更容易增加任务历史和中断恢复。
完整流程应该是:
Code Mode
↓ 用户输入 /plan
Plan Mode
↓ 只读项目、搜索代码、澄清需求
提交 Plan
↓
等待用户审批
├── 继续修改计划
├── 取消
└── 批准
↓
Code Mode
↓
创建 .powercode/tasks/<plan-name>/
↓
写入 PLAN.md 和 TODO.md
↓
修改代码、逐项验证、更新 TODO
这和上一篇的上下文压缩也不是一回事:
Session + Compactor
解决“这一轮模型能看到什么”
Plan Mode
解决“用户批准前,Agent 能做什么”
任务目录 + PLAN.md + TODO.md
解决“这是哪次任务、批准了什么,以及执行到哪里”
三个问题,应该放在 Harness 的不同位置处理。
为什么不能只写一句“请先规划”
最简单的做法,是在 system prompt 里写:
请先分析需求,不要修改代码。
这可以提高模型先思考的概率,但它不是可靠的权限边界。
如果 Harness 仍然把下面这些工具全部交给模型:
read_file
write_file
edit_file
bash
模型依然有能力修改项目。提示词只是在劝它不要写,并没有真正收走写权限。
Claude Code 官方把 Plan Mode 放在 permission mode 体系里:Plan 阶段允许读取和探索,但不修改源码;计划完成后先交给用户审核,批准后再切换到执行权限。
参考资料:Claude Code Permission Modes
这正好能说明 Harness 的作用:
大模型负责生成方案,Harness 负责决定当前状态、提供哪些工具,以及什么时候暂停等待用户。
这一篇采用的组合是:
Claude Code 式权限状态机
+
更明确的规划提示词
+
批准后的 TODO 执行进度
第一版暂时不实现独立 Plan 模型、Plan 子 Agent、上下文清空或全屏审核器。这些都是生产级 Plan Mode 可以继续增加的能力:
- 独立 Plan 模型:规划阶段专门切换到更擅长推理的模型,批准后再换回更快或更便宜的执行模型。它本质上是模型路由,Agent Loop 还是同一个。
- Plan 子 Agent:主 Agent 不亲自做完整规划,而是启动一个拥有独立上下文和工具的子 Agent 研究项目,最后把方案交回主 Agent。这会引入多 Agent 调度和结果交接。
- 上下文清空:用户批准后,不把规划时的整段对话继续交给执行模型,只保留已批准的 Plan 和必要信息。清空的是消息上下文,不是删除项目文件。
- 全屏审核器:不在终端里直接打印一大段 Plan,而是打开独立界面,让用户滚动查看、编辑、批注、批准或退回计划。这属于 TUI 交互增强。
独立 Plan 模型和 Plan 子 Agent 容易混淆:前者只是“换一个大模型思考”,后者是“再启动一个 Agent 完成规划任务”。
本文的第一版使用同一个模型、同一个 Session 和普通 CLI 审批菜单。我们先把最重要的状态边界做对。
第一步:建立 Plan Mode 状态机
这一步先不涉及大模型和工具调用。我们只做一个专门保存 Plan Mode 运行状态的对象,让 Harness 随时能回答三个问题:
当前是 Plan Mode 还是 Code Mode?
计划正在生成,还是已经等待审批?
当前保存的是待审批计划,还是已批准计划?
这些答案之后会决定 Agent 能看到哪些工具,以及 Agent Loop 是继续运行还是暂停等待用户。
等下的代码中还会出现一个 PlanDraft。先不要被这个名字吓到,它只是 Harness 内部保存“待审批计划”的数据格式:title 用来创建任务目录,content 保存完整的 Markdown 计划正文,steps 用来生成和更新 TODO。
先新建 src/plan.ts:
完整实现放在 GitHub:src/plan.ts。
1. mode 和 status 分别管什么
这里故意把 mode 和 status 分开:
mode
控制 Agent 当前拥有哪些能力
例如 Plan Mode 看不到 write_file 和 bash
status
记录 Agent Loop 已经走到哪个阶段
例如正在规划、等待审批或正在执行
为什么不只定义一个布尔值?
planMode: boolean
因为 planMode = true 只能告诉我们“当前在 Plan Mode”,却无法表达下面两种完全不同的情况:
mode = plan
status = planning
Agent 还在读项目、搜代码和生成方案
mode = plan
status = waiting_for_approval
计划已经提交,Agent Loop 必须暂停
两种状态下的工具边界虽然一样,但前者还可以继续调用模型,后者只能等待用户选择。
2. Harness 怎样接住模型生成的计划
前面我们一直在说,计划需要包含目标、实现步骤、风险和验证方式。
这些内容最终就是一份普通的 Markdown:
# 重构登录模块
## 目标
在兼容旧接口的前提下重构登录流程。
## 实现步骤
1. 找到登录入口
2. 梳理旧接口调用方
3. 增加兼容层
## 风险
旧版本客户端可能仍然调用原接口。
## 验证方式
运行登录测试,并手动验证旧客户端。
这里先把一个容易混淆的地方说清楚:
大模型可以直接理解整份 Markdown。我们不需要先把风险、验证方式等章节提取出来,再重新交给它。
用户批准以后,Harness 可以把完整计划放进模型上下文,也可以告诉模型 PLAN.md 的路径,让它自己读取。两种方式都可以。
不管是 Harness 主动把计划放进去,还是 Agent 使用
read_file读取,计划正文最终都要进入模型上下文,大模型才能理解和执行。PLAN.md的真正价值,是把批准后的计划持久化:即使上下文后来被压缩或丢失,Agent 仍然可以重新读取它。
既然如此,为什么还要定义 PlanDraft?
因为这一篇还希望 Harness 自动完成两件事情:
根据计划标题创建任务目录
根据执行步骤生成 TODO.md
如果让 Harness 自己从自由格式的 Markdown 中猜标题和步骤,解析会很不稳定。但目标、背景、风险和验证说明只需要给用户与大模型阅读,没有必要都拆成字段。
因此第一版只结构化程序真正需要处理的部分:
export interface PlanDraft {
title: string;
content: string;
steps: string[];
}
interface 只是 TypeScript 用来描述数据形状的方式。PlanDraft 不是一个新 Agent,也不是一份额外的草稿文件,它只是一个保存待审批计划的 JavaScript 对象格式。
为什么名字里有 Draft?
因为模型刚提交时,这份计划还没有经过用户批准,它只能算是“待审核的计划草案”。批准之前,用户还可以要求模型修改或者取消它。
模型产生的数据会这样流转:
模型调用 submit_plan
↓ 提交 title、content、steps
Harness 得到 PlanDraft
↓
保存到 pendingPlan
↓ 用户批准
移入 approvedPlan
↓
生成 PLAN.md 和 TODO.md
现在再看每个字段,就比较容易理解了:
title
计划名称,后面用来生成任务目录名
content
完整的 Markdown 计划正文
目标、方案、风险和验证方式都写在这里
steps
从 content 中提炼出的可执行步骤
用来生成和更新 TODO.md
注意,这里不是 Harness 先保存 content,再从 Markdown 里解析出 steps。模型调用 submit_plan 时会同时提交这三个字段,Harness 只负责检查它们是否为非空字符串或数组。
这里会有少量重复:content 里会写实现方案,steps 又把可执行项单独列了一遍。这是有意为之。
content
给用户审核,也给大模型执行
steps
给 Harness 生成和跟踪 TODO
Plan Mode 提示词会要求两者保持一致。第一版只能约束格式和非空,不能从语义上证明两者完全相同;但这已经足够完成教学 Demo。这样既保留了 Markdown 的表达能力,又不需要 Harness 自己解析整份文档。
3. pendingPlan 和 approvedPlan 有什么区别
这两个字段保存的都是 PlanDraft,但信任级别不同:
pendingPlan
模型已经提交,但用户还没批准
它可以被要求修改,也可以被取消
approvedPlan
用户已经批准
Agent 可以把它当成后续执行的正式依据
批准时做的关键动作,就是把同一份计划从“待审批区”移到“已批准区”:
pendingPlan
↓ 用户批准
approvedPlan
这也是 approvePlan() 里面这几行代码的含义:
const plan = this.pendingPlan;
this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;
注意,“模型生成了计划”并不代表“计划已经生效”。只有用户批准以后,它才会进入 approvedPlan。
4. 这些方法实际上是状态转换入口
AgentState 并不负责调用大模型,也不负责写入 PLAN.md。它只负责保存状态,并确保状态只能通过下面这些入口改变:
enterPlan():进入 Plan Mode,把状态切到planning,并清理上一次未完成的计划。enterCode():手动回到 Code Mode,相当于放弃当前规划流程。submitPlan():只允许在 Plan Mode 调用,保存待审批计划,并进入waiting_for_approval。refinePlan():用户要求修改时,从等待审批返回planning。approvePlan():把待审批计划转成已批准计划,切换到 Code Mode 并开始执行。cancelPlan():取消计划,清空两个计划字段并回到idle。finishExecution():执行结束后,把executing恢复为idle,并清除只在本次执行期间使用的approvedPlan。已经落盘的PLAN.md和TODO.md不受影响。
把它们串起来,就能看到完整的状态机:
这张图可以分成两部分看:先看方框表示的“当前状态”,再看箭头表示的“状态怎样发生变化”。
先看四个状态方框
绿色方框表示 Code Mode,可以修改文件和执行命令;橙色方框表示 Plan Mode,只能读取、搜索和提交计划。
每个方框中又记录了两项信息:
mode
code 表示 Code Mode
plan 表示 Plan Mode
它决定当前可以使用哪些工具
status
idle、planning、waiting_for_approval、executing
它决定 Agent Loop 正在空闲、规划、等待审批,还是执行任务
所以,mode 回答的是“Agent 现在拥有哪些能力”,status 回答的是“Agent 现在进行到了哪一步”。
再看方框之间的箭头
箭头上的中文表示是谁触发了这次变化,例如“用户批准”或“模型提交计划”;下面的函数名表示 Harness 实际调用的状态转换入口,例如 approvePlan() 或 submitPlan()。
也就是说,状态不能由模型随便改,而只能经过图中这些明确的方法发生变化。
例如,模型调用 submitPlan() 后,只是从 planning 进入 waiting_for_approval,此时仍然处于 Plan Mode,写工具不会恢复。接下来 Agent Loop 必须停住,只能等待用户选择继续修改、取消或批准。
只有用户选择批准,approvePlan() 才会同时完成两件事:把 mode 切回 code,再把 status 切到 executing。这时 Harness 才创建任务文件并开始执行。
在 submitPlan()、refinePlan() 和 approvePlan() 里面主动检查当前状态,是为了防止跳过正常流程。例如,在 Code Mode 下直接提交计划,或在没有待审批计划时直接批准,都应该立即报错。
5. formatPlan() 只负责补上标题
content 已经是完整的 Markdown 计划正文,所以 formatPlan() 不需要理解或重新排列里面的章节。
title + content
↓ formatPlan()
可以展示和保存的完整 PLAN.md
它不会批准计划、不会切换模式,也不会写文件,只负责把标题和正文拼在一起。目标、方案、风险和验证方式仍然保留在模型生成的 content 中。
第二步:给 CLI 增加模式命令
Claude Code 可以通过 Shift+Tab 切换 Plan Mode。
我们的第一版先使用三个文本命令:
/plan 进入 Plan Mode
/code 返回 Code Mode
/status 查看当前模式和状态
原因很简单:当前项目使用 readline.question() 做逐行输入。如果现在就捕获 Shift+Tab,还需要处理原始按键、终端转义序列和输入框刷新,文章会从 Agent Harness 跑到 TUI 实现上。
但快捷键和文本命令最终应该调用同一个状态切换函数:
/plan ──────────┐
├── state.enterPlan()
Shift+Tab ──────┘
所以第一版先把状态机做好,以后增加快捷键不需要修改 Agent 核心。
新建 src/options.ts
完整实现放在 GitHub:src/options.ts。
parseCliOptions() 让用户在启动 PowerCode 时可以决定两件事:Agent 一开始进入哪种模式,以及它要操作哪个项目目录。
本文使用下面这条命令启动:
npm start -- --plan -dir ./workspace/task-board
其中,npm start 后面的第一个 -- 只是 npm 的参数分隔符,表示把后面的内容继续交给 PowerCode。真正属于 PowerCode 的参数是:
--plan
启动后直接进入 Plan Mode
-dir ./workspace/task-board
把 workspace/task-board 设为 Agent 的工作目录
这两个参数彼此独立:--plan 决定初始模式,-dir 决定工具可以读取、搜索和修改的目录。本文把它们放在一起,是为了让 Agent 启动后立即规划练习项目,同时避免它拿 powercode 自己的源码做实验。
本文显式使用
-dir,是因为我们目前还在 PowerCode 的源码目录中通过npm start调试 CLI。将来 PowerCode 发布为 npm 全局命令后,用户只需要进入自己的项目再运行powercode,process.cwd()对应的当前项目目录就会自动成为 Agent 的工作空间;-dir只作为从其他位置指定项目时的可选覆盖参数保留。
也就是说,未来最常见的使用方式应该是:
cd ~/projects/my-app
powercode --plan
只有不方便先进入目标项目时,才需要显式指定:
powercode --plan -dir ~/projects/my-app
第三步:增加两个真正只读的探索工具
第五篇已经有 read_file,但读取项目还需要两项基础能力:
list_files
看目录结构
search_files
按关键词查代码
新建 src/tools/list-files.ts
完整实现放在 GitHub:src/tools/list-files.ts。
先别急着看递归,我们先看这个工具从输入到输出到底做了什么。
假设项目中有下面这些文件:
src/
├── agent.ts
├── main.ts
└── tools/
├── list-files.ts
└── read-file.ts
模型想查看 src 目录时,会发起这样的工具调用:
{
"path": "src"
}
list_files 执行完成后,返回给模型的不是 JavaScript 数组,也不是文件内容,而是一段按行排列的普通文本:
src/agent.ts
src/main.ts
src/tools/
src/tools/list-files.ts
src/tools/read-file.ts
目录会在结尾加上 /,方便模型区分它和普通文件。模型拿到这张“项目地图”以后,才会决定接下来用 read_file 读取哪个文件,或者用 search_files 搜索哪个关键词。
如果没有传 path,工具默认从项目根目录 . 开始;如果目录是空的,就返回“目录为空。”。所以它的职责非常单一:只告诉模型项目里有哪些文件和目录,不读取文件正文。
这个工具要做的事情,其实就是从指定目录开始,一层一层打开子目录,再把看到的文件路径记录下来:
模型传入 path
↓
把 path 解析成工作目录内的绝对路径
↓
使用 readdir() 读取当前目录
↓
记录文件和子目录的相对路径
↓
遇到子目录就继续向下遍历
↓
把结果按行返回给模型
核心是下面这个递归过程:
const entries = await readdir(directory, {
withFileTypes: true,
});
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue;
}
if (
entry.isDirectory() &&
SKIPPED_DIRECTORIES.has(entry.name)
) {
continue;
}
const fullPath = join(directory, entry.name);
const displayPath = toDisplayPath(
relative(this.workDir, fullPath),
);
items.push(
entry.isDirectory()
? `${displayPath}/`
: displayPath,
);
if (entry.isDirectory()) {
await walk(fullPath);
}
}
withFileTypes: true 会让 readdir() 同时告诉我们每一项是文件还是目录。遇到目录时,walk() 再进去读取下一层;返回给模型时使用的是相对于工作目录的路径,而不是用户电脑上的绝对路径。
代码还做了三层收口:
- 跳过符号链接,避免遍历到工作目录之外或形成循环;
- 跳过
.git、.powercode、dist和node_modules,减少无关内容; - 最多返回 200 项,避免一个大项目的目录列表一次占满模型上下文。
这里仍然复用了第四篇的 resolveInWorkDir()。它会先确认模型传入的 path 仍然位于 workDir 之内,再允许 walk() 开始遍历。
即使工具只读,也必须限制路径不能逃出工作目录。只读不等于可以读取用户电脑上的任意文件。
新建 src/tools/search-files.ts
完整实现放在 GitHub:src/tools/search-files.ts。
这个搜索工具没有正则表达式、管道和重定向,只做一件明确的事:在工作目录内查找文本。
这比把完整 Bash 交给 Plan Mode 更容易解释,也更容易测试。
这两个工具都会跳过 .powercode。里面放的是 PowerCode 生成的 PLAN.md 和 TODO.md,不是项目源码;规划时把这些旧任务记录也搜出来,只会干扰模型。进入执行阶段后,Agent 仍然可以按照审批消息里的准确路径,用 read_file 和 edit_file 读取、更新它们。
第四步:让 Registry 同时负责“隐藏”和“拦截”
只是不把写工具的定义发给模型,还不够完整。
完整文件可以对照 GitHub:src/tools/registry.ts。下面只看这一篇需要修改的两个方法。
模型通常不会调用一个没有见过的工具,但 Harness 仍然应该在真正执行前再次检查:
第一层
不把 write_file、edit_file、bash 放进 tools 参数
第二层
即使模型生成了这些工具名,Registry 也拒绝执行
修改 src/tools/registry.ts 中的两个方法:
- 给
getDefinitions()增加allowedNames参数,只把当前模式允许的工具定义发给模型; - 给
execute()增加同一个参数,在真正执行前再次拦截不允许的工具。
register() 和其他代码保持不变,只需要替换下面两个方法:
getDefinitions(
allowedNames?: ReadonlySet<string>,
): OpenAI.Chat.Completions.ChatCompletionTool[] {
return [...this.tools.values()]
.filter(
(tool) =>
!allowedNames || allowedNames.has(tool.name),
)
.map((tool) => tool.definition);
}
async execute(
name: string,
argumentsJson: string,
allowedNames?: ReadonlySet<string>,
): Promise<string> {
if (allowedNames && !allowedNames.has(name)) {
throw new Error(
`当前模式不允许调用工具:${name}`,
);
}
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`找不到工具:${name}`);
}
return tool.execute(argumentsJson);
}
到这里,Plan Mode 的“只读”已经不只是提示词了。
真正的边界是:
const PLAN_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"submit_plan",
]);
write_file、edit_file 和 bash 不在集合里,所以模型看不到,Registry 也不会执行。
第五步:增加 submit_plan 工具
如果计划只是普通 Markdown 文本,Agent Loop 很难准确判断:
模型是在解释思路?
还是已经提交最终计划?
现在该继续调用模型?
还是应该暂停等待用户?
所以增加一个明确的控制工具:
submit_plan
模型表示“计划已经完成”
Harness 把状态切到 waiting_for_approval
Agent Loop 暂停
新建 src/tools/submit-plan.ts:
完整实现放在 GitHub:src/tools/submit-plan.ts。
先看它从输入到输出做了什么。
模型完成规划后,会调用 submit_plan,同时传入三个字段:
{
"title": "重构登录流程",
"content": "## 目标\n在兼容旧接口的前提下重构登录流程。\n\n## 实现方案\n先找到登录入口和调用方,再增加兼容层。\n\n## 风险\n旧客户端可能仍在调用原接口。\n\n## 验证方式\n运行登录测试,并验证旧接口。",
"steps": [
"找到登录入口和旧接口调用方",
"增加兼容层并修改登录流程",
"运行登录测试并验证旧接口"
]
}
这里的三个字段各有去处:
title
后面用来生成任务目录名
content
用户将要审核的完整 Markdown 计划
批准后写入 PLAN.md
steps
批准后转换成 TODO.md 中的待办项
工具收到参数后,会先检查 title 和 content 是不是非空字符串,再检查 steps 是不是一个非空字符串数组。格式不对就直接报错,不会把一份残缺的计划放进状态机。
真正执行计划提交的核心只有三行:
async execute(argumentsJson: string): Promise<string> {
const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);
return "计划已提交,等待用户审批。";
}
parsePlan() 把模型传来的 JSON 转成 PlanDraft,state.submitPlan() 则完成两件事:
pendingPlan = plan
status = waiting_for_approval
因此,工具直接返回给 Agent Loop 的结果只是一句话:
计划已提交,等待用户审批。
但这句话不是最终展示给用户的计划。Agent Loop 发现状态已经变成 waiting_for_approval 后,会停止继续请求模型,再从 pendingPlan 中取出完整计划交给 CLI 展示。
把整个过程连起来就是:
模型调用 submit_plan(title, content, steps)
↓
parsePlan() 校验并生成 PlanDraft
↓
state.submitPlan() 保存到 pendingPlan
↓
状态切换为 waiting_for_approval
↓
Agent Loop 暂停
↓
CLI 展示完整计划,等待用户选择
不过要注意:这几步不是全部写在 submit-plan.ts 里的,而是由三个文件接力完成的。这里先把接力关系讲清楚,agent.ts 和 main.ts 的具体修改会在后面的第七步和第九步完成。
1. submit-plan.ts:只负责提交计划
const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);
执行到这里,计划被放进 pendingPlan,状态也变成了 waiting_for_approval。但 submit_plan 自己不会停止 Agent Loop,更不会显示审批菜单。
2. agent.ts:发现状态变化后退出 Agent Loop
本轮工具全部执行完以后,Agent.run() 会检查当前状态:
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("计划状态异常。");
}
return formatPlan(plan);
}
关键是最后的 return。它会直接结束当前的 run(),因此外层 for (let step...) 不会再进入下一轮,也就不会继续请求模型。
所以这里说的“Agent Loop 暂停”,准确来说是:
停止继续调用模型
↓
把完整计划返回给 CLI
↓
Node.js 进程继续运行,等待用户输入
3. main.ts:打印计划并打开审批菜单
CLI 原本就是按下面的顺序调用:
await runPrompt(value);
await reviewPendingPlan();
runPrompt() 会打印 Agent.run() 返回的完整计划;接着 reviewPendingPlan() 发现状态仍然是 waiting_for_approval,便显示三个选项:
1. 批准并执行
2. 继续修改计划
3. 取消
然后它通过 readline.question() 等待用户选择。到这里,“提交计划 → 停止 Agent Loop → CLI 展示计划并等待审批”才算真正闭环。
这个工具不会修改用户源码,也不会创建 PLAN.md 和 TODO.md。那两份文件只有在用户批准计划、Harness 切回 Code Mode 以后才会生成。
第六步:给 Plan Mode 注入专属提示词
现在权限边界已经由 Harness 保证,提示词只负责提高计划质量。
完整文件可以对照 GitHub:src/agent.ts。正文继续保留与 Plan Mode 有关的关键片段。
修改 src/agent.ts 时,先补上状态相关的导入:
import {
AgentState,
formatPlan,
} from "./plan.ts";
然后把原来的单条 system prompt 拆成三段:
const CORE_SYSTEM_PROMPT = `
你是 power-code,一个研发助手。
请优先读取真实文件;修改后主动运行命令验证结果;请使用中文回答。
所有文件路径都相对于当前工作目录,不能操作工作目录之外的文件。
`;
const PLAN_MODE_PROMPT = `
# 当前模式:Plan Mode
你现在只能研究项目和制定计划,不能修改文件或执行命令。
请按以下顺序工作:
1. 先使用 list_files、search_files 和 read_file 了解真实项目。
2. 能从项目中找到的答案,不要询问用户。
3. 只有关键需求或取舍无法从项目确认时,才向用户提出问题并等待回答。
4. 不要把普通猜测写成已经确定的事实。
5. content 必须是一份完整的 Markdown 计划正文,写清目标、方案、风险和验证方式,不重复 title 对应的一级标题。
6. steps 必须从 content 的实施方案中提炼,按执行顺序排列,并包含必要的验证步骤。
7. content 和 steps 不能出现两套不同的方案。
8. 计划完成后必须调用 submit_plan,不要只输出一段普通 Markdown。
`;
const EXECUTION_PROMPT = `
# 当前模式:Code Mode
下面的计划已经由用户批准。
批准消息会给出本次任务的 PLAN.md 和 TODO.md 路径。
请先读取这两份文件,然后按照 TODO.md 从上到下执行。
- 完成一个有结果的步骤后,立即把对应的 - [ ] 更新为 - [x]。
- 修改代码但还没有完成对应验证时,不能勾选包含验证工作的步骤。
- 遇到错误时先读取本次任务的 TODO.md 确认当前位置,再修复并重新验证。
- 不得擅自改变已经批准的目标和范围。
`;
这里借鉴的是一种很实用的规划顺序:
先探索
↓
能从代码确认的先自己确认
↓
只询问真正需要用户决定的问题
↓
生成可以直接执行的计划
注意,提示词不是安全边界。
即使模型忽略了“不能修改文件”,Plan Mode 也拿不到写工具;这才是前面修改 Registry 的意义。
第七步:根据模式动态提供工具
继续修改 src/agent.ts。
先处理一个容易被忽略的地方。第五篇为了演示最小版 Agent Loop,把最大执行轮数设成了 8:
const MAX_STEPS = 8;
对于“读取项目、生成计划、修改多个文件、逐项验证”这样的长任务,8 轮通常不够。把它调整为:
const MAX_STEPS = 30;
这里的“一轮”指的是模型完成一次思考并返回结果,不等于只调用一个工具。模型可能在同一轮里连续调用多个 read_file 或 write_file,所以日志中会出现多个“第 8 轮”。
MAX_STEPS 仍然要保留,它是防止 Agent 因为重复搜索、反复修改而无限循环的安全上限。30 也不是固定标准,只是更适合这一篇长任务 Demo 的起点;以后可以再把它做成配置项。
先在 Agent 类外定义两组工具名,可以放在前面的提示词常量后面:
const PLAN_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"submit_plan",
]);
const CODE_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"write_file",
"edit_file",
"bash",
]);
然后给 Agent 增加 AgentState:
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
private readonly session: Session,
private readonly state: AgentState,
) {
this.compactor = new Compactor(
client,
client.getContextWindow(),
);
}
接着把下面两个私有方法放进 Agent 类内部,位置就在 constructor 后面、run() 前面:
export class Agent {
constructor(...) {
...
}
// 在这里加入 getAllowedToolNames()
// 在这里加入 buildSystemPrompt()
async run(...) {
...
}
}
具体代码如下:
private getAllowedToolNames(): ReadonlySet<string> {
return this.state.getMode() === "plan"
? PLAN_TOOL_NAMES
: CODE_TOOL_NAMES;
}
private buildSystemPrompt(): string {
if (this.state.getMode() === "plan") {
return CORE_SYSTEM_PROMPT + PLAN_MODE_PROMPT;
}
const approvedPlan = this.state.getApprovedPlan();
if (approvedPlan) {
return (
CORE_SYSTEM_PROMPT +
EXECUTION_PROMPT +
`\n\n${formatPlan(approvedPlan)}`
);
}
return CORE_SYSTEM_PROMPT;
}
这里正是“把整份计划直接交给大模型”的位置。formatPlan(approvedPlan) 会把 title + content 原样加入执行阶段的 system prompt,大模型不需要依赖 Harness 解析风险或验证方式。后面再写入 PLAN.md,是为了把批准结果持久化,方便执行中重读和以后回看。
调用模型时,不再直接获取全部工具:
const allowedToolNames =
this.getAllowedToolNames();
const completion = await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(allowedToolNames),
);
执行工具时也传入同一份集合:
result = await this.registry.execute(
name,
toolCall.function.arguments,
allowedToolNames,
);
最后,在 run() 的外层 Agent Loop 中找到下面这段工具循环:
for (const toolCall of toolCalls) {
// 执行工具,并把 tool result 写入 Session
}
把状态检查放在这个工具循环的右花括号后面、外层 for (let step...) 进入下一轮之前:
for (const toolCall of toolCalls) {
// 原有的工具执行代码
}
// 加在这里:本轮工具已经全部执行完,
// 但还没有再次请求大模型。
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("计划状态异常。");
}
return formatPlan(plan);
}
不要把它放到 for (const toolCall...) 里面,也不要放到整个 run() 外面。放在这里可以保证本轮所有工具结果已经写入 Session,同时在下一次请求模型前及时停住。
这样,模型调用 submit_plan 后,Agent Loop 不会继续让它自己批准自己,而是立刻返回 CLI。
单线程里,Agent Loop 怎么停下来等用户
这里的“暂停”并不是把正在执行的 for 循环挂在内存里,同时开另一条线程询问用户。
先分清两个很容易混在一起的动作:
模型调用 submit_plan
表示“计划已经写完,可以交给用户看了”
用户批准计划
表示“我同意这个方案,现在可以执行了”
submit_plan 是模型发起的工具调用,不需要用户在这一刻输入内容。Plan Mode 提示词已经告诉模型:读完项目、计划写好以后,必须调用这个工具。
因此,agent.run() 启动 Agent Loop 后,可能会经历下面几轮:
第 1 轮:模型调用 list_files
第 2 轮:模型调用 search_files
第 3 轮:模型调用 read_file
第 4 轮:模型认为信息足够,调用 submit_plan
模型具体在第几轮提交并不固定。每一轮请求大模型时,它都可以选择继续探索,也可以返回一个 submit_plan 工具调用。Harness 收到第 4 轮的工具调用后,会像执行其他工具一样执行它:
const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);
这时只是把状态改成了 waiting_for_approval。等本轮工具执行结束,Agent Loop 走到下面这个固定检查点:
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
return formatPlan(plan);
}
条件成立,于是执行 return,本次 Agent Loop 到这里结束。随后 CLI 才会展示计划并询问用户,而不是在 submit_plan 工具内部和用户交互。
先把实际代码缩成最关键的两层:
async function runPrompt(value: string) {
const answer = await agent.run(value); // ②
console.log(`AI:${answer}`); // ④
}
await runPrompt(value); // ①
await reviewPendingPlan(); // ⑤
假设用户输入“帮我重构登录流程”,程序会这样往下走:
main.ts执行第 ① 行,调用runPrompt()。因为前面有await,第 ⑤ 行暂时不会执行。runPrompt()执行第 ② 行,调用agent.run()。它也会停在这里,等待 Agent Loop 返回结果。- Agent 读完项目并调用
submit_plan,状态变成waiting_for_approval,随后执行:
return formatPlan(plan); // ③
这句 return 会结束本次 agent.run(),把完整计划交回第 ② 行的 answer,不是把 for 循环挂在那里。
runPrompt()拿到answer,执行第 ④ 行打印计划,然后函数结束。- 第 ① 行的
await到此完成,main.ts才继续执行第 ⑤ 行,进入reviewPendingPlan()并询问用户。
可以把它看成普通的嵌套函数调用,只是中间多了异步等待:
调用:main.ts → runPrompt() → agent.run()
返回:main.ts ← runPrompt() ← agent.run()
全部返回以后,main.ts 才调用 reviewPendingPlan()
整个过程仍然是单线程顺序执行的,没有 Agent Loop 和审批菜单同时运行。await 的意思就是“先等这个函数完成,再执行下一行”。
用户批准计划后,CLI 会再次调用:
await runPrompt(
"计划已经批准,请开始执行……",
);
这会启动一次新的 agent.run(),而不是回到刚才那个已经结束的 for 循环。新的调用仍然使用同一个 AgentState 和 Session,因此它可以拿到已批准计划和前面的对话,再从 Code Mode 开始执行。
所以更准确地说:
不是“挂起旧 Agent Loop,批准后原地恢复”
而是“结束规划调用,等待审批,再发起新的执行调用”
src/agent.ts 的完整关键结构
整合后的 run() 主体如下:
async run(prompt: string): Promise<string> {
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
throw new Error("当前计划正在等待审批。");
}
this.session.append({
role: "user",
content: prompt,
});
for (let step = 1; step <= MAX_STEPS; step += 1) {
const memory =
await this.compactor.buildWorkingMemory(
this.session,
);
const allowedToolNames =
this.getAllowedToolNames();
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
[
{
role: "system",
content: this.buildSystemPrompt(),
},
...memory,
];
const completion =
await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(
allowedToolNames,
),
);
const message = completion.message;
if (!message) {
throw new Error("模型没有返回消息。");
}
this.session.append(message);
if (
completion.totalTokens !== undefined &&
completion.totalTokens > 0
) {
this.session.saveUsage(
completion.totalTokens,
);
}
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
this.state.finishExecution();
return (
message.content ?? "模型没有返回文本内容。"
);
}
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(
`暂时不支持工具类型:${toolCall.type}`,
);
}
const name = toolCall.function.name;
console.log(
`第 ${step} 轮:AI 调用 ${name}`,
);
let result: string;
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
allowedToolNames,
);
console.log(`✓ ${name} 执行完成\n`);
} catch (error) {
const reason =
error instanceof Error
? error.message
: String(error);
result = `工具执行失败:${reason}`;
console.log(`✗ ${result}\n`);
}
this.session.append({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("计划状态异常。");
}
return formatPlan(plan);
}
}
throw new Error(
`执行超过 ${MAX_STEPS} 轮,已停止。`,
);
}
原来的 Session 和 Compactor 不需要删除。
Plan 阶段读取的项目内容仍然进入当前会话,过长时仍然由 Compactor 构造工作记忆。我们只是改变了每一轮能使用的工具。
第八步:为每个计划创建独立任务目录
为什么不在 Plan Mode 一开始就创建独立的 TODO.md?
因为这时方案还没有批准。
计划本身完全可以包含未勾选的步骤清单,Codex 就会采用这种表达方式。但在本文的双文件设计里,TODO.md 代表已经进入执行阶段的正式进度。
如果用户要求继续修改计划或直接取消,工作目录里就不应该出现一份看起来已经开始执行的 TODO.md。
所以顺序是:
模型提交 Plan
↓
CLI 展示给用户
↓
用户批准
↓
Harness 根据 Plan 标题生成安全目录名
↓
Harness 创建 .powercode/tasks/<plan-name>/
↓
Harness 写入 PLAN.md 和 TODO.md
任务目录名不能直接使用模型返回的原始标题。标题里可能有空格、斜杠、.. 或其他不适合进入路径的字符,同名计划也可能相互覆盖。
所以 Harness 需要先把标题转换成安全名称,再加上时间标识:
计划标题
重构登录模块,兼容旧接口
任务目录
.powercode/tasks/20260817-143012000-重构登录模块-兼容旧接口/
这个 .powercode 位于用户通过 -dir 指定的执行目录中,不是固定写进 Harness 自己的源码目录。本文的启动命令使用 -dir ./workspace/task-board,因此实际位置是:
powercode/workspace/task-board/.powercode/tasks/<plan-name>/
新建 src/plan-files.ts:
完整实现放在 GitHub:src/plan-files.ts。
PLAN.md 保存完整的 content,因此目标、背景、风险和验证方式都不会丢失。TODO.md 只根据 steps 生成;如果某项验证必须实际执行,就应该把它作为一个明确步骤放进 steps。
这里使用 Unicode 字母和数字白名单处理标题,斜杠、..、标点和连续空格都会被替换掉。时间部分精确到毫秒,用来降低同名任务发生覆盖的可能性。
这里的写入不是模型在 Plan Mode 调用 write_file。
它发生在用户点击批准之后,由 Harness 自己完成:
Plan Mode
模型没有写工具
用户批准
Harness 切到 Code Mode
Code Mode
Harness 创建本次任务目录、PLAN.md 和 TODO.md
Agent 开始执行
这条边界要在文章和代码里都说清楚。
第九步:在 CLI 里完成审批流程
最后修改 src/main.ts。
完整文件可以对照 GitHub:src/main.ts。下面只保留参数接线、状态显示、审批菜单和命令处理这些关键修改。
先在原有导入旁边增加:
import { AgentState } from "./plan.ts";
import { saveApprovedPlan } from "./plan-files.ts";
import { ListFilesTool } from "./tools/list-files.ts";
import { SearchFilesTool } from "./tools/search-files.ts";
import { SubmitPlanTool } from "./tools/submit-plan.ts";
import { parseCliOptions } from "./options.ts";
再解析参数、注册工具和状态:
const options = parseCliOptions(
process.argv.slice(2),
);
const workDir = resolve(process.cwd(), options.dir);
const state = new AgentState(
options.plan ? "plan" : "code",
);
const registry = new Registry();
registry.register(new ReadFileTool(workDir));
registry.register(new ListFilesTool(workDir));
registry.register(new SearchFilesTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
registry.register(new SubmitPlanTool(state));
const agent = new Agent(
client,
registry,
session,
state,
);
printStatus() 是 src/main.ts 的顶层辅助函数。把它放在 agent 创建完成之后、reviewPendingPlan() 和最下面的输入循环之前:
const state = new AgentState(...);
const registry = new Registry();
const agent = new Agent(...);
// 在这里加入 printStatus()
// 后面再定义 reviewPendingPlan()
// 最后才是 while (true) 输入循环
具体代码如下:
function printStatus(): void {
console.log(`当前模式:${state.getMode()}`);
console.log(`当前状态:${state.getStatus()}`);
const tools =
state.getMode() === "plan"
? "read_file, list_files, search_files, submit_plan"
: "read_file, list_files, search_files, write_file, edit_file, bash";
console.log(`可用工具:${tools}`);
}
再增加计划审批:
async function reviewPendingPlan(): Promise<void> {
while (
state.getStatus() ===
"waiting_for_approval"
) {
console.log(`
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
`);
const choice = (
await readline.question("选择:")
).trim();
if (choice === "1") {
const plan = state.approvePlan();
const files = await saveApprovedPlan(
workDir,
plan,
);
console.log(
"\n计划已批准,已进入 Code Mode。",
);
console.log(
`已创建任务目录:${files.taskDir}\n`,
);
await runPrompt(
`计划已经批准。
计划文件:${files.planPath}
执行清单:${files.todoPath}
请先读取这两份文件,从第一项未完成任务开始执行;每完成一项后立即更新这份 TODO.md。`,
);
return;
}
if (choice === "2") {
const feedback = (
await readline.question(
"请输入计划修改意见:",
)
).trim();
if (!feedback) {
console.log("修改意见不能为空。");
continue;
}
state.refinePlan();
await runPrompt(
`请根据下面的反馈修改计划,并再次调用 submit_plan:\n${feedback}`,
);
continue;
}
if (choice === "3") {
state.cancelPlan();
console.log(
"\n计划已取消,已返回 Code Mode。",
);
return;
}
console.log("请输入 1、2 或 3。");
}
}
为什么这里用 while,而不是 if
if 只检查一次,while 则会在条件成立时反复执行:
判断 status 是否为 waiting_for_approval
├── 是:执行一次审批逻辑,然后重新判断
└── 否:结束审批循环
所以,决定循环是否继续的是 while 的条件,不是 continue。即使没有 continue,代码执行到循环体末尾后,也会自动回到开头重新判断。
continue 的作用只是跳过当前迭代剩余的代码,提前回到条件判断:
没有 continue
执行完本轮剩余代码,再判断 while 条件
遇到 continue
跳过本轮剩余代码,立即判断 while 条件
放回审批流程就很好理解了。假设用户第一次要求修改计划,第二次再批准:
第 1 次迭代
用户选择“继续修改”
↓
refinePlan():status 变成 planning
↓
await runPrompt():模型修改并重新提交计划
↓
status 再次变成 waiting_for_approval
↓
continue:回到 while 开头
第 2 次迭代
while 条件仍然成立,再次显示审批菜单
↓
用户选择“批准”
↓
approvePlan():status 变成 executing
↓
return:结束 reviewPendingPlan()
当前代码使用 continue,是为了在“继续修改”处理完后跳过下面的取消判断和错误提示。即使删掉它,while 仍然可能进入下一次迭代,只是中间会继续执行本轮剩余代码。
如果用户第一次就批准或取消,函数会直接 return,审批循环实际上只执行一遍。循环里的 await readline.question() 会等待用户输入,因此它也不是一直占用 CPU 的死循环。
最后在原来的输入循环中处理命令:
while (true) {
const value = (
await readline.question("> ")
).trim();
if (!value) continue;
if (value === "exit" || value === "quit") {
break;
}
if (value === "/plan") {
state.enterPlan();
console.log(
"已进入 Plan Mode,只能读取和搜索项目。",
);
continue;
}
if (value === "/code") {
state.enterCode();
console.log("已进入 Code Mode。");
continue;
}
if (value === "/status") {
printStatus();
continue;
}
try {
await runPrompt(value);
await reviewPendingPlan();
} catch (error) {
const message =
error instanceof Error
? error.message
: String(error);
console.error(`本轮执行失败:${message}`);
}
}
现在三个命令真正对应 Harness 状态:
/plan
切换状态
收走写工具
/code
返回普通模式
恢复写工具
/status
查看模式、阶段和当前工具面
它们不是发给大模型的普通聊天内容。
跑一次完整流程
准备一个练习目录,例如:
workspace/task-board/
└── src/
└── ceshi.ts
启动项目:
npm run build
npm start -- --plan -dir ./workspace/task-board
先查看状态:
> /status
当前模式:plan
当前状态:planning
可用工具:read_file, list_files, search_files, submit_plan
输入任务:
请把 src/ceshi.ts 改造成一个可以直接运行的小型待办演示。
先读取项目并给出计划,不要马上修改。
这一阶段应该看到类似的工具调用:
list_files
↓
read_file
↓
search_files
↓
submit_plan
不应该出现:
write_file
edit_file
bash
计划提交后,CLI 展示完整 Plan:
# 增加命令行待办演示
## 目标
把现有脚本改造成可以直接运行的待办展示,同时保持实现简单。
## 实现步骤
1. 阅读并保留现有入口结构。
2. 增加待办数据和格式化输出。
3. 运行 node src/ceshi.ts。
4. 确认终端输出包含全部待办项。
## 风险
- 当前文件扩展名与 Node 运行方式可能不兼容。
## 验证方式
- 运行 node src/ceshi.ts。
- 确认终端输出包含全部待办项。
然后出现审批菜单:
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
选择 2 时,Agent 仍然处于 Plan Mode,只能继续读取、搜索和重新提交计划。
选择 1 时:
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-增加命令行待办演示/
用户指定的执行目录中会出现:
workspace/task-board/
├── src/
│ └── ceshi.ts
└── .powercode/
└── tasks/
└── 20260817-143012000-增加命令行待办演示/
├── PLAN.md
└── TODO.md
这时才可能出现:
read_file
.powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md
edit_file
src/ceshi.ts
edit_file
.powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md
bash
node src/ceshi.ts
最终该任务目录中的 TODO.md 类似:
# 执行清单
## 实现步骤
- [x] 阅读并保留现有入口结构
- [x] 增加待办数据和格式化输出
- [x] 运行 node src/ceshi.ts
- [x] 确认终端输出包含全部待办项
这里有一条需要诚实说明:
“Plan Mode 不能写代码”由 Harness 强制保证;“完成一步就更新 TODO”在第一版里仍然主要依靠执行提示词。
如果以后想让 TODO 更新也变成强约束,可以再增加专门的 todo_update 工具,由 Harness 记录步骤状态,而不是让模型直接编辑 Markdown。
/code 和“批准并执行”有什么区别
两者都会进入 Code Mode,但含义不同。
/code
手动退出 Plan Mode
不代表计划已经批准
不自动生成 TODO
批准并执行
明确批准当前 Plan
创建本次任务目录
保存 PLAN.md 和 TODO.md
自动开始执行
正常流程应该使用“批准并执行”。
/code 更像一个逃生口:用户不想继续规划时,可以直接返回普通模式。
为什么第一版不做 Shift+Tab
不是不能做,而是它不属于这一篇最重要的部分。
当前 CLI 是逐行输入:
await readline.question("> ");
要捕获 Shift+Tab,通常需要监听按键事件,并处理终端发送的转义序列。
无论用户通过什么方式切换,底层最终都只是:
state.enterPlan();
或者:
state.enterCode();
因此正确的实现顺序是:
先实现状态机和权限边界
↓
再实现 /plan、/code
↓
最后把 Shift+Tab 映射到相同函数
等以后给 powercode 增加完整 TUI 时,再补快捷键会更自然。
几个容易踩的坑
1. 把 Plan Mode 写成一段提示词
如果模型仍然拿得到写工具,就不能说 Harness 已经进入只读模式。
正确做法是同时隐藏工具定义,并在执行前再次检查允许列表。
2. Plan 一提交就自动执行
这会让“审批”只剩下界面文字。
submit_plan 调用后必须把状态切到:
waiting_for_approval
然后暂停 Agent Loop。
3. 在批准前把计划清单当成执行进度
计划里可以有待勾选的任务分解,但在用户批准之前,它们仍然是方案的一部分,不应显示成“正在执行”。
本文选择在批准后才生成独立的 TODO.md。如果你选择 Codex 这种单文档方案,也要在状态上区分“待审批”和“执行中”。
4. 给 Plan Mode 整个 Bash
不能只检查命令是不是以 cat、grep、find 开头。管道、重定向、命令替换和子进程都会让字符串判断变得复杂。
第一版使用专用只读工具更清楚。
5. 认为 Plan 和 TODO 必须存成两份文件
文件数量不是关键,状态边界才是。
可以像本文一样,用稳定的 PLAN.md 保存批准方案,用持续变化的 TODO.md 记录执行进度;也可以像 Codex 一样,在计划文档中直接保留勾选清单。
只要 Harness 清楚记录“待审批”和“执行中”,两种方案都是合理的。
6. 直接用 Plan 标题当文件夹名
Plan 标题由模型生成,不能直接当作可信的路径。必须先移除斜杠、.. 和特殊字符,限制长度,并加入时间或任务 ID 避免同名覆盖。
7. 让模型自己批准计划
模型可以生成计划,但批准权属于用户。submit_plan 之后必须回到 CLI,而不是再发一句“请确认计划可行”让模型自我判断。
8. 把敏感信息写进计划文件
.powercode/tasks/ 中的 PLAN.md 和 TODO.md 可能进入 Git。不要把 API Key、Cookie、访问令牌或完整敏感日志写进去。
如果这些只是本地运行状态,可以把 .powercode/ 加入 .gitignore;如果团队希望把计划当作项目文档保留,再有选择地提交。
到这里,我们真正给 Agent 增加了什么
AgentState
保存 mode、status、待审批计划和已批准计划
Plan Mode
只允许读取、列目录、搜索和提交计划
submit_plan
把普通模型输出变成明确的工作流事件
用户审批
决定继续修改、取消,还是切换到执行模式
任务目录
根据 Plan 标题生成安全名称,隔离每次任务的计划和进度记录
PLAN.md + TODO.md
分别保存已批准的方案和实时执行进度
最重要的变化不是多了两个 Markdown 文件,也不是多了 /plan 命令。
而是 Harness 第一次开始管理 Agent 的工作阶段:
规划阶段
只能研究,不能执行
审批阶段
Agent Loop 暂停,等待人类决定
执行阶段
恢复写工具,按照批准的计划完成任务
这就是 Plan Mode 最值得学习的地方:
它不是一句“请先想清楚”的提示词,而是一套由 Harness 控制的只读、审批和执行状态机。
下一篇预告
现在,powercode 已经会先看项目、写计划,等我们批准以后再照着 TODO 一步步执行。
但真正拿它去改项目,很快又会碰到一个特别现实的问题:
bash 执行失败了
↓
Agent 换个参数又执行一次
↓
还是同样的错误
↓
继续重复,直到耗尽最大轮数
有时它还会在验证没有真正通过时,就提前把 TODO 标成完成。Plan Mode 能管住“先想清楚再动手”,却还管不住 Agent 在失败以后应该怎么自救。
所以下一篇,也就是这个系列的第七篇,咱们继续解决:
AI 连续报错怎么办?让它学会自救,而不是原地打转
我们会给 powercode 加上失败分类、重复失败检测和恢复提示。让 Harness 不只是把报错原样丢回模型,而是能告诉它:哪里失败了、是不是已经重复失败,以及下一步应该换什么方式继续。