🚀 欢迎来到「不用框架,手搓 AI Agent」系列第六篇。
即使没有读过前面的文章,也可以直接从这一篇开始。你只需要知道:我们已经有了一个能读取、修改文件和执行命令的最小 AI Agent。
这一篇,我们要让它学会一件更像工程师的事:面对长任务时,先调查、再规划,等人批准以后才开始写代码。
先讲一个公司里的小故事
上周五,隔壁同事探过头来:
"登录这块能不能顺手改一下?现在新旧两套接口看着有点乱,最好再补几个测试。"
"行,我先让 Agent 看看,应该挺快。"
你把需求复制给 Agent,然后起身接了杯水。
等你回来,它已经开始干活了:
text
read_file
src/login.ts
read_file
src/routes.ts
edit_file
src/login.ts
edit_file
src/...
看起来还挺顺。没过多久,Agent 就回复:
"登录模块已经重构完成。"
你正准备收工,同事扫了一眼改动:
"等一下,你怎么改的是这个文件?"
"这不是登录入口吗?"
"这个早就不用了啊。线上走的是另外一层。还有那个旧接口不能删,客户端还在调。"
你赶紧往下翻,越看越不对:
text
真正的登录入口还没读
旧接口有哪些调用方也没搜全
兼容逻辑被当成旧代码删了
测试命令一次都没跑
你问 Agent:
"为什么没先把调用关系查完整?"
它的回答也很合理:
"我根据当前读取到的文件完成了重构。"
这下问题就很清楚了。
你说的"先看看",是想让它先把项目摸清楚;它理解的"先看看",是读两个文件,然后边看边改。
模型不一定不会写代码。只是我们一开始就把 write_file、edit_file 和 bash 全给了它,它当然可能在还没弄清楚的时候直接开干。
如果是把任务交给同事,我们多半会补一句:
"你先别改。把入口、调用方、兼容方案、可能踩的坑,还有准备怎么测都列一下。我看完没问题,你再动手。"
Plan Mode 到底是什么
Plan Mode 可以理解成 Agent 的"只做方案,暂不施工"阶段。
我们先以 Claude Code 为例。
在可以执行修改的模式下,Claude Code 的目标是把任务直接做完:
text
读取项目
↓
修改文件
↓
执行命令
↓
验证结果
切换到 Plan Mode 之后,Claude Code 会暂时收紧修改源码的能力,先读取和搜索项目、形成计划,然后等待用户审批。
这时它的目标不是完成代码,而是先回答:
text
真正需要改什么
涉及哪些文件和调用关系
准备按什么顺序实现
有哪些风险
最后怎样验证
因此,进入 Plan Mode 后,Agent 应该经历三个阶段:
text
规划阶段
读取、搜索和理解项目
↓
审批阶段
提交计划,暂停等待用户确认
↓
执行阶段
用户批准后进入 Code Mode
恢复修改和命令执行能力
所以 Plan Mode 不是另一种大模型,也不只是生成一个 PLAN.md。
它首先是 Harness 中的一种运行状态:
text
当前处于什么阶段
↓
决定模型能看到哪些工具
↓
决定 Agent Loop 什么时候必须暂停
计划内容由大模型生成,但"批准前不能修改"和"提交后必须等待"应该由 Harness 保证。
为什么 Agent 总想马上改代码
这里先澄清一下:大模型不是真的"性格冲动"。
它只是会根据当前目标、上下文和可用工具,选择一个看起来最能推进任务的动作。
当用户说:
text
帮我重构登录模块
Harness 又同时提供:
text
read_file
write_file
edit_file
bash
那么从模型的角度看,读取和修改都是合法动作。它读到一两个相关文件后,很容易判断"信息已经够了",下一步自然就是调用 edit_file。
Agent Loop 还会继续推动它向前:
text
用户要求修改代码
↓
模型看到写工具
↓
读取少量文件
↓
调用 edit_file
↓
Harness 返回"修改成功"
↓
模型继续修改下一个文件
整个循环里,没有任何一步要求它:
text
先证明自己已经找全入口
先列出影响范围
先把方案交给用户
等用户批准后才能继续
所以它不是故意乱来,而是在我们提供的 Harness 里,"直接开改"本来就是一条畅通无阻的路径。
即使在提示词里加一句:
text
请先了解清楚,再修改代码。
模型仍然需要自己判断什么叫"了解清楚"。它可能读完两个文件,就认为这个条件已经满足了。
所以Plan Mode 做的第一件事,就是从能力层把这条路先堵住:
text
Plan Mode 可用
├── read_file
├── list_files
├── search_files
└── submit_plan
Plan Mode 不可用
├── write_file
├── edit_file
└── bash
这时即使模型觉得"可以开始改了",它也没有修改文件的工具。它能做的只有继续读取、继续搜索,或者调用 submit_plan 把方案交出来。
不过,收走写工具只解决了"不能提前修改"。
要让它真的形成"先了解,再确认"的流程,还需要另外两层:
text
规划提示词
要求先探索项目,计划中写清步骤、风险和验证方式
审批状态机
submit_plan 后暂停 Agent Loop,必须等待用户选择
所以这一篇实现的并不是一个工具开关,而是三层配合:
text
工具限制
保证批准前不能写
规划提示词
引导模型先把项目了解清楚
用户审批
决定什么时候恢复写工具
这个骨架并不是我们凭空发明的。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 也遵守同样的流程:
text
先进入 Plan Mode
↓
只读取和搜索项目
↓
提交实施计划
↓
等待用户审批
↓
批准后进入 Code Mode
↓
修改代码并更新执行进度
最终的终端体验会是这样:
text
> /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再执行。
最后的工具边界是:
text
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 只需要完成三件事:
text
read_file
只读取一个文件
list_files
只列出目录结构
search_files
只搜索文本
这三个工具已经足够让 Agent 看清目录结构、找到相关代码并读取文件内容。
如果再把 Bash 交给 Plan Mode,我们就还要判断每条 Shell 命令到底只是查询,还是可能写文件、删文件或访问网络。这会引入很多和本文主线无关的边界处理。
像 Codex 和 Claude Code 这类成熟产品,可以继续通过命令解析、权限规则、用户审批和沙箱来约束 Bash。但对我们的第一版来说,最清楚的选择就是:
Plan Mode 只提供规划真正需要的专用查询工具,暂时不提供 Bash。
系列目录
- 不用框架,手搓 AI Agent:(一)先让它跑起来
- 不用 LangChain,手搓 AI Agent:给大模型装上"手",让它自己读项目文件
- 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
- Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
- 不用 LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
- 本文:AI Agent 如何执行长任务?给它加上 Plan Mode 和任务清单
- 更多实战持续更新中......
🚀 本节配套源码:powercode 👈 点它
如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐
先说明仓库和目录
本文继续使用主项目 powercode。
第五篇结束后,我们已经把当时的代码冻结在:
text
powercode/demos/05-session-compaction
这个目录是第五篇的独立 Demo,用来回看 Session 和 Compactor,不再继续修改。
第六篇要改的是主项目源码:
text
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
三个目录不要混在一起:
text
powercode/src
我们正在开发的 Agent Harness
powercode/demos/05-session-compaction
第五篇的冻结版本
powercode/workspace/task-board
Agent 实际读取、搜索和修改的练习项目
后面的代码都基于第五篇完成后的主项目继续添加。没有读过前文也没关系:本文涉及的新文件和关键修改都会完整给出。
先说结论:Plan 和 TODO 是两个概念,但可以放在同一份文档
Plan 和 TODO 经常一起出现,但它们解决的不是同一个问题。
text
Plan
决定准备怎么做
发生在执行之前
需要用户审核
TODO
记录现在做到哪一步
发生在计划批准之后
随执行进度更新
有些 Agent 会把两者放在同一份文档里。例如 Codex 生成的计划文档中,就可能直接出现这样的勾选清单:
markdown
- [ ] 找到登录模块的真实入口
- [ ] 梳理旧接口的调用方
- [ ] 实现兼容层
- [ ] 补充测试并验证
在计划还没有批准时,这些勾选项表示的是"准备怎么做",本质上仍然是 Plan 的任务分解。如果 Agent 在批准后继续更新勾选状态,这份文档就同时承担了 TODO 的职责。
所以真正需要分开的是两种状态:
text
批准前
这是待审核的计划清单
批准后
这是正在执行的进度清单
至于物理上是一个文件还是两个文件,属于 Harness 的实现选择。这一篇为了让职责更清楚,会为每次批准的计划创建一个独立任务目录,再在里面保存两份文件:
text
.powercode/tasks/<plan-name>/
├── PLAN.md
│ 保存用户批准的方案,尽量保持稳定
└── TODO.md
从计划步骤生成,执行时持续更新
这样不同任务的计划和进度不会互相覆盖,以后也更容易增加任务历史和中断恢复。
完整流程应该是:
text
Code Mode
↓ 用户输入 /plan
Plan Mode
↓ 只读项目、搜索代码、澄清需求
提交 Plan
↓
等待用户审批
├── 继续修改计划
├── 取消
└── 批准
↓
Code Mode
↓
创建 .powercode/tasks/<plan-name>/
↓
写入 PLAN.md 和 TODO.md
↓
修改代码、逐项验证、更新 TODO
这和上一篇的上下文压缩也不是一回事:
text
Session + Compactor
解决"这一轮模型能看到什么"
Plan Mode
解决"用户批准前,Agent 能做什么"
任务目录 + PLAN.md + TODO.md
解决"这是哪次任务、批准了什么,以及执行到哪里"
三个问题,应该放在 Harness 的不同位置处理。
为什么不能只写一句"请先规划"
最简单的做法,是在 system prompt 里写:
text
请先分析需求,不要修改代码。
这可以提高模型先思考的概率,但它不是可靠的权限边界。
如果 Harness 仍然把下面这些工具全部交给模型:
text
read_file
write_file
edit_file
bash
模型依然有能力修改项目。提示词只是在劝它不要写,并没有真正收走写权限。
Claude Code 官方把 Plan Mode 放在 permission mode 体系里:Plan 阶段允许读取和探索,但不修改源码;计划完成后先交给用户审核,批准后再切换到执行权限。
参考资料:Claude Code Permission Modes
这正好能说明 Harness 的作用:
大模型负责生成方案,Harness 负责决定当前状态、提供哪些工具,以及什么时候暂停等待用户。
这一篇采用的组合是:
text
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 随时能回答三个问题:
text
当前是 Plan Mode 还是 Code Mode?
计划正在生成,还是已经等待审批?
当前保存的是待审批计划,还是已批准计划?
这些答案之后会决定 Agent 能看到哪些工具,以及 Agent Loop 是继续运行还是暂停等待用户。
等下的代码中还会出现一个 PlanDraft。先不要被这个名字吓到,它只是 Harness 内部保存"待审批计划"的数据格式:title 用来创建任务目录,content 保存完整的 Markdown 计划正文,steps 用来生成和更新 TODO。
先新建 src/plan.ts:
ts
export type AgentMode = "code" | "plan";
export type AgentStatus =
| "idle"
| "planning"
| "waiting_for_approval"
| "executing";
export interface PlanDraft {
title: string;
content: string;
steps: string[];
}
export class AgentState {
private mode: AgentMode;
private status: AgentStatus;
private pendingPlan?: PlanDraft;
private approvedPlan?: PlanDraft;
constructor(initialMode: AgentMode = "code") {
this.mode = initialMode;
this.status = initialMode === "plan" ? "planning" : "idle";
}
getMode(): AgentMode {
return this.mode;
}
getStatus(): AgentStatus {
return this.status;
}
getPendingPlan(): PlanDraft | undefined {
return this.pendingPlan;
}
getApprovedPlan(): PlanDraft | undefined {
return this.approvedPlan;
}
enterPlan(): void {
this.mode = "plan";
this.status = "planning";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
enterCode(): void {
this.mode = "code";
this.status = "idle";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
submitPlan(plan: PlanDraft): void {
if (
this.mode !== "plan" ||
this.status !== "planning"
) {
throw new Error("只有规划阶段可以提交计划。");
}
this.pendingPlan = plan;
this.status = "waiting_for_approval";
}
refinePlan(): void {
if (
this.status !== "waiting_for_approval" ||
!this.pendingPlan
) {
throw new Error("当前没有等待修改的计划。");
}
this.status = "planning";
}
approvePlan(): PlanDraft {
if (
this.status !== "waiting_for_approval" ||
!this.pendingPlan
) {
throw new Error("当前没有等待批准的计划。");
}
const plan = this.pendingPlan;
this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;
return plan;
}
cancelPlan(): void {
this.mode = "code";
this.status = "idle";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
finishExecution(): void {
if (this.status === "executing") {
this.status = "idle";
this.approvedPlan = undefined;
}
}
}
export function formatPlan(plan: PlanDraft): string {
return `# ${plan.title}
${plan.content.trim()}
`;
}
1. mode 和 status 分别管什么
这里故意把 mode 和 status 分开:
text
mode
控制 Agent 当前拥有哪些能力
例如 Plan Mode 看不到 write_file 和 bash
status
记录 Agent Loop 已经走到哪个阶段
例如正在规划、等待审批或正在执行
为什么不只定义一个布尔值?
ts
planMode: boolean
因为 planMode = true 只能告诉我们"当前在 Plan Mode",却无法表达下面两种完全不同的情况:
text
mode = plan
status = planning
Agent 还在读项目、搜代码和生成方案
mode = plan
status = waiting_for_approval
计划已经提交,Agent Loop 必须暂停
两种状态下的工具边界虽然一样,但前者还可以继续调用模型,后者只能等待用户选择。
2. Harness 怎样接住模型生成的计划
前面我们一直在说,计划需要包含目标、实现步骤、风险和验证方式。
这些内容最终就是一份普通的 Markdown:
markdown
# 重构登录模块
## 目标
在兼容旧接口的前提下重构登录流程。
## 实现步骤
1. 找到登录入口
2. 梳理旧接口调用方
3. 增加兼容层
## 风险
旧版本客户端可能仍然调用原接口。
## 验证方式
运行登录测试,并手动验证旧客户端。
这里先把一个容易混淆的地方说清楚:
大模型可以直接理解整份 Markdown。我们不需要先把风险、验证方式等章节提取出来,再重新交给它。
用户批准以后,Harness 可以把完整计划放进模型上下文,也可以告诉模型 PLAN.md 的路径,让它自己读取。两种方式都可以。
不管是 Harness 主动把计划放进去,还是 Agent 使用
read_file读取,计划正文最终都要进入模型上下文,大模型才能理解和执行。PLAN.md的真正价值,是把批准后的计划持久化:即使上下文后来被压缩或丢失,Agent 仍然可以重新读取它。
既然如此,为什么还要定义 PlanDraft?
因为这一篇还希望 Harness 自动完成两件事情:
text
根据计划标题创建任务目录
根据执行步骤生成 TODO.md
如果让 Harness 自己从自由格式的 Markdown 中猜标题和步骤,解析会很不稳定。但目标、背景、风险和验证说明只需要给用户与大模型阅读,没有必要都拆成字段。
因此第一版只结构化程序真正需要处理的部分:
ts
export interface PlanDraft {
title: string;
content: string;
steps: string[];
}
interface 只是 TypeScript 用来描述数据形状的方式。PlanDraft 不是一个新 Agent,也不是一份额外的草稿文件,它只是一个保存待审批计划的 JavaScript 对象格式。
为什么名字里有 Draft?
因为模型刚提交时,这份计划还没有经过用户批准,它只能算是"待审核的计划草案"。批准之前,用户还可以要求模型修改或者取消它。
模型产生的数据会这样流转:
text
模型调用 submit_plan
↓ 提交 title、content、steps
Harness 得到 PlanDraft
↓
保存到 pendingPlan
↓ 用户批准
移入 approvedPlan
↓
生成 PLAN.md 和 TODO.md
现在再看每个字段,就比较容易理解了:
text
title
计划名称,后面用来生成任务目录名
content
完整的 Markdown 计划正文
目标、方案、风险和验证方式都写在这里
steps
从 content 中提炼出的可执行步骤
用来生成和更新 TODO.md
注意,这里不是 Harness 先保存 content,再从 Markdown 里解析出 steps。模型调用 submit_plan 时会同时提交这三个字段,Harness 只负责检查它们是否为非空字符串或数组。
这里会有少量重复:content 里会写实现方案,steps 又把可执行项单独列了一遍。这是有意为之。
text
content
给用户审核,也给大模型执行
steps
给 Harness 生成和跟踪 TODO
Plan Mode 提示词会要求两者保持一致。第一版只能约束格式和非空,不能从语义上证明两者完全相同;但这已经足够完成教学 Demo。这样既保留了 Markdown 的表达能力,又不需要 Harness 自己解析整份文档。
3. pendingPlan 和 approvedPlan 有什么区别
这两个字段保存的都是 PlanDraft,但信任级别不同:
text
pendingPlan
模型已经提交,但用户还没批准
它可以被要求修改,也可以被取消
approvedPlan
用户已经批准
Agent 可以把它当成后续执行的正式依据
批准时做的关键动作,就是把同一份计划从"待审批区"移到"已批准区":
text
pendingPlan
↓ 用户批准
approvedPlan
这也是 approvePlan() 里面这几行代码的含义:
ts
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不受影响。
把它们串起来,就能看到完整的状态机:
这张图需要分成两层来看:
text
方框里的 mode
code 表示 Code Mode
plan 表示 Plan Mode
它决定当前能使用哪些工具
方框里的 status
idle、planning、waiting_for_approval、executing
决定 Agent Loop 正在规划、等待,还是执行
箭头上的方法
表示状态只能通过哪个入口发生变化
例如,模型调用 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() 不需要理解或重新排列里面的章节。
text
title + content
↓ formatPlan()
可以展示和保存的完整 PLAN.md
它不会批准计划、不会切换模式,也不会写文件,只负责把标题和正文拼在一起。目标、方案、风险和验证方式仍然保留在模型生成的 content 中。
第二步:给 CLI 增加模式命令
Claude Code 可以通过 Shift+Tab 切换 Plan Mode。
我们的第一版先使用三个文本命令:
text
/plan 进入 Plan Mode
/code 返回 Code Mode
/status 查看当前模式和状态
原因很简单:当前项目使用 readline.question() 做逐行输入。如果现在就捕获 Shift+Tab,还需要处理原始按键、终端转义序列和输入框刷新,文章会从 Agent Harness 跑到 TUI 实现上。
但快捷键和文本命令最终应该调用同一个状态切换函数:
text
/plan ──────────┐
├── state.enterPlan()
Shift+Tab ──────┘
所以第一版先把状态机做好,以后增加快捷键不需要修改 Agent 核心。
新建 src/options.ts
ts
export interface CliOptions {
dir: string;
plan: boolean;
}
function readValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${name} 后面需要一个值。`);
}
return value;
}
export function parseCliOptions(args: string[]): CliOptions {
return {
dir: readValue(args, "-dir") ?? ".",
plan: args.includes("--plan"),
};
}
parseCliOptions() 让用户在启动 PowerCode 时可以决定两件事:Agent 一开始进入哪种模式,以及它要操作哪个项目目录。
本文使用下面这条命令启动:
bash
npm start -- --plan -dir ./workspace/task-board
其中,npm start 后面的第一个 -- 只是 npm 的参数分隔符,表示把后面的内容继续交给 PowerCode。真正属于 PowerCode 的参数是:
text
--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只作为从其他位置指定项目时的可选覆盖参数保留。
也就是说,未来最常见的使用方式应该是:
bash
cd ~/projects/my-app
powercode --plan
只有不方便先进入目标项目时,才需要显式指定:
bash
powercode --plan -dir ~/projects/my-app
第三步:增加两个真正只读的探索工具
第五篇已经有 read_file,但读取项目还需要两项基础能力:
text
list_files
看目录结构
search_files
按关键词查代码
新建 src/tools/list-files.ts
ts
import { readdir } from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";
const MAX_ITEMS = 200;
const SKIPPED_DIRECTORIES = new Set([
".git",
".powercode",
"dist",
"node_modules",
]);
function parsePath(argumentsJson: string): string {
const input = JSON.parse(argumentsJson) as {
path?: unknown;
};
if (input.path !== undefined && typeof input.path !== "string") {
throw new Error("path 必须是字符串。");
}
return input.path ?? ".";
}
function toDisplayPath(path: string): string {
return path.replaceAll("\\", "/");
}
export class ListFilesTool implements Tool {
readonly name = "list_files";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description: "递归列出当前项目中的文件和目录。",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "相对于项目根目录的目录,默认是当前项目根目录",
},
},
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const path = parsePath(argumentsJson);
const start = resolveInWorkDir(this.workDir, path);
const items: string[] = [];
const walk = async (directory: string): Promise<void> => {
if (items.length >= MAX_ITEMS) {
return;
}
const entries = await readdir(directory, {
withFileTypes: true,
});
entries.sort((left, right) =>
left.name.localeCompare(right.name),
);
for (const entry of entries) {
if (items.length >= MAX_ITEMS) {
return;
}
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);
}
}
};
await walk(start);
if (items.length === 0) {
return "目录为空。";
}
const suffix =
items.length >= MAX_ITEMS
? `\n...[最多返回 ${MAX_ITEMS} 项]...`
: "";
return items.join("\n") + suffix;
}
}
这里仍然复用了第四篇的 resolveInWorkDir()。
即使工具只读,也必须限制路径不能逃出工作目录。只读不等于可以读取用户电脑上的任意文件。
新建 src/tools/search-files.ts
ts
import {
readFile,
readdir,
stat,
} from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";
const MAX_RESULTS = 50;
const MAX_FILE_BYTES = 200_000;
const SKIPPED_DIRECTORIES = new Set([
".git",
".powercode",
"dist",
"node_modules",
]);
interface SearchInput {
query: string;
path: string;
}
function parseInput(argumentsJson: string): SearchInput {
const input = JSON.parse(argumentsJson) as {
query?: unknown;
path?: unknown;
};
if (
typeof input.query !== "string" ||
!input.query.trim()
) {
throw new Error("query 必须是非空字符串。");
}
if (
input.path !== undefined &&
typeof input.path !== "string"
) {
throw new Error("path 必须是字符串。");
}
return {
query: input.query,
path: input.path ?? ".",
};
}
function toDisplayPath(path: string): string {
return path.replaceAll("\\", "/");
}
export class SearchFilesTool implements Tool {
readonly name = "search_files";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"在当前项目的文本文件中搜索关键词。",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "要搜索的文本关键词",
},
path: {
type: "string",
description:
"相对于项目根目录的搜索目录,默认是项目根目录",
},
},
required: ["query"],
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const input = parseInput(argumentsJson);
const start = resolveInWorkDir(
this.workDir,
input.path,
);
const query = input.query.toLowerCase();
const results: string[] = [];
const walk = async (directory: string): Promise<void> => {
if (results.length >= MAX_RESULTS) {
return;
}
const entries = await readdir(directory, {
withFileTypes: true,
});
for (const entry of entries) {
if (results.length >= MAX_RESULTS) {
return;
}
if (entry.isSymbolicLink()) {
continue;
}
if (
entry.isDirectory() &&
SKIPPED_DIRECTORIES.has(entry.name)
) {
continue;
}
const fullPath = join(directory, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
const fileStat = await stat(fullPath);
if (fileStat.size > MAX_FILE_BYTES) {
continue;
}
const content = await readFile(fullPath, "utf8");
if (content.includes("\0")) {
continue;
}
const displayPath = toDisplayPath(
relative(this.workDir, fullPath),
);
const lines = content.split(/\r?\n/);
for (
let index = 0;
index < lines.length;
index += 1
) {
const line = lines[index];
if (line.toLowerCase().includes(query)) {
results.push(
`${displayPath}:${index + 1}: ${line.trim()}`,
);
}
if (results.length >= MAX_RESULTS) {
return;
}
}
}
};
await walk(start);
if (results.length === 0) {
return `没有找到关键词:${input.query}`;
}
const suffix =
results.length >= MAX_RESULTS
? `\n...[最多返回 ${MAX_RESULTS} 条结果]...`
: "";
return results.join("\n") + suffix;
}
}
这个搜索工具没有正则表达式、管道和重定向,只做一件明确的事:在工作目录内查找文本。
这比把完整 Bash 交给 Plan Mode 更容易解释,也更容易测试。
注意两个工具都默认跳过 .powercode。这里保存的是 Harness 工件,不是项目源码;如果后续规划时反复搜到历史 Plan 和 TODO,反而可能干扰模型判断。执行阶段仍然可以通过 read_file 和 edit_file 访问审批消息中给出的精确任务文件路径。
第四步:让 Registry 同时负责"隐藏"和"拦截"
只是不把写工具的定义发给模型,还不够完整。
模型通常不会调用一个没有见过的工具,但 Harness 仍然应该在真正执行前再次检查:
text
第一层
不把 write_file、edit_file、bash 放进 tools 参数
第二层
即使模型生成了这些工具名,Registry 也拒绝执行
修改 src/tools/registry.ts:
ts
import type OpenAI from "openai";
import type { Tool } from "./types.ts";
export class Registry {
private readonly tools = new Map<string, Tool>();
register(tool: Tool): void {
if (this.tools.has(tool.name)) {
throw new Error(`工具重复注册:${tool.name}`);
}
this.tools.set(tool.name, tool);
}
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 的"只读"已经不只是提示词了。
真正的边界是:
ts
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 很难准确判断:
text
模型是在解释思路?
还是已经提交最终计划?
现在该继续调用模型?
还是应该暂停等待用户?
所以增加一个明确的控制工具:
text
submit_plan
模型表示"计划已经完成"
Harness 把状态切到 waiting_for_approval
Agent Loop 暂停
新建 src/tools/submit-plan.ts:
ts
import {
AgentState,
type PlanDraft,
} from "../plan.ts";
import type { Tool } from "./types.ts";
function parseString(
input: Record<string, unknown>,
name: string,
): string {
const value = input[name];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${name} 必须是非空字符串。`);
}
return value.trim();
}
function parseStringArray(
input: Record<string, unknown>,
name: string,
): string[] {
const value = input[name];
if (
!Array.isArray(value) ||
value.some(
(item) =>
typeof item !== "string" || !item.trim(),
)
) {
throw new Error(`${name} 必须是字符串数组。`);
}
if (value.length === 0) {
throw new Error(`${name} 不能为空。`);
}
return value.map((item) => item.trim());
}
function parsePlan(argumentsJson: string): PlanDraft {
const input: unknown = JSON.parse(argumentsJson);
if (
typeof input !== "object" ||
input === null ||
Array.isArray(input)
) {
throw new Error("计划参数必须是对象。");
}
const record = input as Record<string, unknown>;
return {
title: parseString(record, "title"),
content: parseString(record, "content"),
steps: parseStringArray(record, "steps"),
};
}
export class SubmitPlanTool implements Tool {
readonly name = "submit_plan";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"提交最终实施计划,并暂停等待用户审批。",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "计划标题",
},
content: {
type: "string",
description:
"完整的 Markdown 计划正文,包含目标、方案、风险和验证方式,不重复一级标题",
},
steps: {
type: "array",
items: { type: "string" },
description: "按执行顺序排列的实施步骤",
},
},
required: [
"title",
"content",
"steps",
],
additionalProperties: false,
},
},
};
constructor(private readonly state: AgentState) {}
async execute(argumentsJson: string): Promise<string> {
const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);
return "计划已提交,等待用户审批。";
}
}
这个工具不修改用户源码。
它只把计划标题、Markdown 正文和执行步骤写进 AgentState,然后改变 Harness 状态。计划内容稍后由 CLI 展示给用户。
第六步:给 Plan Mode 注入专属提示词
现在权限边界已经由 Harness 保证,提示词只负责提高计划质量。
修改 src/agent.ts 时,先补上状态相关的导入:
ts
import {
AgentState,
formatPlan,
} from "./plan.ts";
然后把原来的单条 system prompt 拆成三段:
ts
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 确认当前位置,再修复并重新验证。
- 不得擅自改变已经批准的目标和范围。
`;
这里借鉴的是一种很实用的规划顺序:
text
先探索
↓
能从代码确认的先自己确认
↓
只询问真正需要用户决定的问题
↓
生成可以直接执行的计划
注意,提示词不是安全边界。
即使模型忽略了"不能修改文件",Plan Mode 也拿不到写工具;这才是前面修改 Registry 的意义。
第七步:根据模式动态提供工具
继续修改 src/agent.ts。
定义两组工具:
ts
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:
ts
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
private readonly session: Session,
private readonly state: AgentState,
) {
this.compactor = new Compactor(
client,
client.getContextWindow(),
);
}
增加两个辅助方法:
ts
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,是为了把批准结果持久化,方便执行中重读和以后回看。
调用模型时,不再直接获取全部工具:
ts
const allowedToolNames =
this.getAllowedToolNames();
const completion = await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(allowedToolNames),
);
执行工具时也传入同一份集合:
ts
result = await this.registry.execute(
name,
toolCall.function.arguments,
allowedToolNames,
);
最后,在一轮工具调用结束后检查状态:
ts
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("计划状态异常。");
}
return formatPlan(plan);
}
这样,模型调用 submit_plan 后,Agent Loop 不会继续让它自己批准自己,而是立刻返回 CLI。
src/agent.ts 的完整关键结构
整合后的 run() 主体如下:
ts
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。
所以顺序是:
text
模型提交 Plan
↓
CLI 展示给用户
↓
用户批准
↓
Harness 根据 Plan 标题生成安全目录名
↓
Harness 创建 .powercode/tasks/<plan-name>/
↓
Harness 写入 PLAN.md 和 TODO.md
任务目录名不能直接使用模型返回的原始标题。标题里可能有空格、斜杠、.. 或其他不适合进入路径的字符,同名计划也可能相互覆盖。
所以 Harness 需要先把标题转换成安全名称,再加上时间标识:
text
计划标题
重构登录模块,兼容旧接口
任务目录
.powercode/tasks/20260817-143012000-重构登录模块-兼容旧接口/
这个 .powercode 位于用户通过 -dir 指定的执行目录中,不是固定写进 Harness 自己的源码目录。本文的启动命令使用 -dir ./workspace/task-board,因此实际位置是:
text
powercode/workspace/task-board/.powercode/tasks/<plan-name>/
新建 src/plan-files.ts:
ts
import {
mkdir,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import {
formatPlan,
type PlanDraft,
} from "./plan.ts";
export interface PlanFiles {
taskDir: string;
planPath: string;
todoPath: string;
}
function createTaskName(title: string): string {
const safeTitle = title
.normalize("NFKC")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
const digits = new Date()
.toISOString()
.replace(/\D/g, "")
.slice(0, 17);
const timestamp = [
digits.slice(0, 8),
digits.slice(8),
].join("-");
return `${timestamp}-${safeTitle || "plan"}`;
}
function formatTodo(plan: PlanDraft): string {
const steps = plan.steps
.map((item) => `- [ ] ${item}`)
.join("\n");
return `# 执行清单
> 这份清单由已经批准的 PLAN.md 生成。
## 实现步骤
${steps}
`;
}
export async function saveApprovedPlan(
workDir: string,
plan: PlanDraft,
): Promise<PlanFiles> {
const taskName = createTaskName(plan.title);
const taskDir = join(
".powercode",
"tasks",
taskName,
);
const absoluteTaskDir = join(workDir, taskDir);
const planPath = join(taskDir, "PLAN.md");
const todoPath = join(taskDir, "TODO.md");
await mkdir(absoluteTaskDir, {
recursive: true,
});
await Promise.all([
writeFile(
join(workDir, planPath),
formatPlan(plan),
"utf8",
),
writeFile(
join(workDir, todoPath),
formatTodo(plan),
"utf8",
),
]);
return {
taskDir,
planPath,
todoPath,
};
}
PLAN.md 保存完整的 content,因此目标、背景、风险和验证方式都不会丢失。TODO.md 只根据 steps 生成;如果某项验证必须实际执行,就应该把它作为一个明确步骤放进 steps。
这里使用 Unicode 字母和数字白名单处理标题,斜杠、..、标点和连续空格都会被替换掉。时间部分精确到毫秒,用来降低同名任务发生覆盖的可能性。
这里的写入不是模型在 Plan Mode 调用 write_file。
它发生在用户点击批准之后,由 Harness 自己完成:
text
Plan Mode
模型没有写工具
用户批准
Harness 切到 Code Mode
Code Mode
Harness 创建本次任务目录和计划工件
Agent 开始执行
这条边界要在文章和代码里都说清楚。
第九步:在 CLI 里完成审批流程
最后修改 src/main.ts。
先在原有导入旁边增加:
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";
再解析参数、注册工具和状态:
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,
);
增加状态输出:
ts
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}`);
}
再增加计划审批:
ts
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。");
}
}
最后在原来的输入循环中处理命令:
ts
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 状态:
text
/plan
切换状态
收走写工具
/code
返回普通模式
恢复写工具
/status
查看模式、阶段和当前工具面
它们不是发给大模型的普通聊天内容。
跑一次完整流程
准备一个练习目录,例如:
text
workspace/task-board/
└── src/
└── ceshi.ts
启动项目:
bash
npm run build
npm start -- --plan -dir ./workspace/task-board
先查看状态:
text
> /status
当前模式:plan
当前状态:planning
可用工具:read_file, list_files, search_files, submit_plan
输入任务:
text
请把 src/ceshi.ts 改造成一个可以直接运行的小型待办演示。
先读取项目并给出计划,不要马上修改。
这一阶段应该看到类似的工具调用:
text
list_files
↓
read_file
↓
search_files
↓
submit_plan
不应该出现:
text
write_file
edit_file
bash
计划提交后,CLI 展示完整 Plan:
markdown
# 增加命令行待办演示
## 目标
把现有脚本改造成可以直接运行的待办展示,同时保持实现简单。
## 实现步骤
1. 阅读并保留现有入口结构。
2. 增加待办数据和格式化输出。
3. 运行 node src/ceshi.ts。
4. 确认终端输出包含全部待办项。
## 风险
- 当前文件扩展名与 Node 运行方式可能不兼容。
## 验证方式
- 运行 node src/ceshi.ts。
- 确认终端输出包含全部待办项。
然后出现审批菜单:
text
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
选择 2 时,Agent 仍然处于 Plan Mode,只能继续读取、搜索和重新提交计划。
选择 1 时:
text
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-增加命令行待办演示/
用户指定的执行目录中会出现:
text
workspace/task-board/
├── src/
│ └── ceshi.ts
└── .powercode/
└── tasks/
└── 20260817-143012000-增加命令行待办演示/
├── PLAN.md
└── TODO.md
这时才可能出现:
text
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 类似:
markdown
# 执行清单
## 实现步骤
- [x] 阅读并保留现有入口结构
- [x] 增加待办数据和格式化输出
- [x] 运行 node src/ceshi.ts
- [x] 确认终端输出包含全部待办项
这里有一条需要诚实说明:
"Plan Mode 不能写代码"由 Harness 强制保证;"完成一步就更新 TODO"在第一版里仍然主要依靠执行提示词。
如果以后想让 TODO 更新也变成强约束,可以再增加专门的 todo_update 工具,由 Harness 记录步骤状态,而不是让模型直接编辑 Markdown。
/code 和"批准并执行"有什么区别
两者都会进入 Code Mode,但含义不同。
text
/code
手动退出 Plan Mode
不代表计划已经批准
不自动生成 TODO
批准并执行
明确批准当前 Plan
创建本次任务目录
保存 PLAN.md 和 TODO.md
自动开始执行
正常流程应该使用"批准并执行"。
/code 更像一个逃生口:用户不想继续规划时,可以直接返回普通模式。
为什么第一版不做 Shift+Tab
不是不能做,而是它不属于这一篇最重要的部分。
当前 CLI 是逐行输入:
ts
await readline.question("> ");
要捕获 Shift+Tab,通常需要监听按键事件,并处理终端发送的转义序列。
无论用户通过什么方式切换,底层最终都只是:
ts
state.enterPlan();
或者:
ts
state.enterCode();
因此正确的实现顺序是:
text
先实现状态机和权限边界
↓
再实现 /plan、/code
↓
最后把 Shift+Tab 映射到相同函数
等以后给 powercode 增加完整 TUI 时,再补快捷键会更自然。
几个容易踩的坑
1. 把 Plan Mode 写成一段提示词
如果模型仍然拿得到写工具,就不能说 Harness 已经进入只读模式。
正确做法是同时隐藏工具定义,并在执行前再次检查允许列表。
2. Plan 一提交就自动执行
这会让"审批"只剩下界面文字。
submit_plan 调用后必须把状态切到:
text
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 增加了什么
text
AgentState
保存 mode、status、待审批计划和已批准计划
Plan Mode
只允许读取、列目录、搜索和提交计划
submit_plan
把普通模型输出变成明确的工作流事件
用户审批
决定继续修改、取消,还是切换到执行模式
任务目录
根据 Plan 标题生成安全名称,隔离每次任务的工件
PLAN.md + TODO.md
分别保存已批准的方案和实时执行进度
最重要的变化不是多了两个 Markdown 文件,也不是多了 /plan 命令。
而是 Harness 第一次开始管理 Agent 的工作阶段:
text
规划阶段
只能研究,不能执行
审批阶段
Agent Loop 暂停,等待人类决定
执行阶段
恢复写工具,按照批准的计划完成任务
这就是 Plan Mode 最值得学习的地方:
它不是一句"请先想清楚"的提示词,而是一套由 Harness 控制的只读、审批和执行状态机。