返回文章列表

《Claude Code 源码解析系列》第14章|Plan

说明 Claude Code 如何把 Plan Mode 做成带权限边界的运行时机制。

《Claude Code 源码解析系列》第14章|Plan

很多人第一次用 Claude Code 的 Plan Mode,理解都比较朴素:

先让模型写个计划,用户确认后再开始改代码。

这个理解没错,但太浅了。

如果只是在 prompt 里写一句”请先制定计划再执行”,那它只是一个行为建议。模型心情好就遵守,心情不好就跳过,没有硬边界。Claude Code 真正有意思的地方在于,它把”先想后做”做成了一个运行时机制:

用户目标
-> 进入 Plan Mode
-> 切换到只读权限
-> 收集代码库上下文
-> 生成可审批计划
-> 用户批准
-> 恢复原权限模式
-> 进入执行阶段
-> 按计划实施并验证

Plan 不是一段更礼貌的提示词。它是由 工具集、权限模式、状态保存、子 Agent、审批 UI、计划文件和恢复机制 拼出来的一层控制面。

这篇文章就来拆它。

先把源码层的关键词摆出来:在 Claude Code 里,planPermissionMode 的一个明确枚举配置,和 defaultacceptEditsbypassPermissionsauto 处在同一层。它有自己的标题、短标题、图标、颜色和 external 映射。也就是说,Plan 不是某个工具临时加的状态位,而是会进入整条工具权限判断链的运行时模式。

一、Plan 解决的不是”不会写计划”,而是”过早行动”

Agent 写代码时最危险的时刻,往往不是改错了一行代码。而是它还没理解项目,就已经动手了。

比如你让它”给接口加分页”。它可能马上新增 pagepageSize,顺手改几个 handler。改完才发现:

  • 项目已有统一响应格式;
  • 数据层用的是 Prisma;
  • 既有接口偏向 limit/offset
  • 校验层统一用 Zod;
  • 某些列表接口更适合 cursor 分页;
  • 改动还要兼容前端已有调用。

如果 Agent 已经写了一堆代码,这些发现就变成了返工。如果它还处在只读阶段,这些发现只是规划材料,不花一分钱。

所以 Plan Mode 的核心问题不是:

怎样让模型生成一份漂亮计划?

而是:

怎样给 Agent 一个”可以充分理解项目,但不能产生副作用”的合法阶段?

Claude Code 的答案是:把工作拆成两个阶段。

Plan 阶段:读代码、搜上下文、比较方案、问问题、输出计划
Execute 阶段:写文件、运行命令、改代码、验证结果

直白地说,Plan Mode 的第一层本质就是 读写分离

二、入口:EnterPlanModeTool 把”计划”变成状态切换

在 Claude Code 里,进入 Plan Mode 不是单纯改变模型语气。而是通过 EnterPlanModeTool 触发一次运行时状态切换。

从本地源码材料看,这个入口大致做几件事:

用户或模型决定进入 Plan Mode
-> 检查当前是否在子 Agent 上下文
-> 保存进入 Plan 前的权限模式到 prePlanMode
-> 准备 Plan Mode 的权限上下文
-> 将当前 permission mode 切换为 plan
-> 返回 Plan Mode 下的行为指令

更贴近源码的说法是:EnterPlanModeTool 自己声明为 shouldDefer: trueisConcurrencySafe: trueisReadOnly: true。真正调用时,它读取当前 AppState,通过 prepareContextForPlanMode(prev.toolPermissionContext) 准备权限上下文,再用 applyPermissionUpdate(..., { type: "setMode", mode: "plan", destination: "session" }) 把会话切到 plan。

返回给模型的 tool result 会强调“探索代码库、设计实现方案、不要写或编辑文件”。但那段文本只是外层行为说明;硬切换发生在 toolPermissionContext.mode = "plan"

这里有两个关键点。

第一,prePlanMode。

Plan Mode 不是永久模式,只是执行前的临时状态。进入 Plan 前,系统可能处在 defaultautoacceptEdits 等不同权限模式里。退出 Plan 后要能回到原来的执行语境,所以 Claude Code 会把原模式先存下来。

(这个设计很常见,但容易漏。如果忘了保存,退出 Plan 后权限对不上,Agent 可能会突然从”每次都要问”变成”自动全批”,或者反过来。)

第二,子 Agent 上下文限制。

本地材料提到,子 Agent 不应该自己进入 Plan Mode。原因很简单:Plan Mode 最终需要用户审批。如果一个用户看不见的子 Agent 半路弹出审批请求,父 Agent 会卡住,用户也不知道该批准谁的计划。Plan 是主流程控制面,不适合藏在子任务深处。

这说明 Claude Code 对 Plan 的定位很清楚:它不是任意 Agent 都能随手开的”思考开关”,而是主会话里用来控制执行边界的阶段。

三、Plan Mode 的硬边界:权限系统会挡住写操作

Plan Mode 最容易被低估的一点,是它真的改变了工具权限。

在权限模型里,plan 是一个独立模式。它的语义不是”建议你别改”,而是”非只读工具不应该通过权限检查”。

可以把权限检查想成这样:

工具调用进入权限管线
-> 当前是否是 plan mode?
-> 如果工具不是只读工具,直接拒绝
-> 如果是 Read / Grep / Glob 等只读工具,继续正常检查

“先别动手”从 prompt 约束升级成了运行时约束。模型想改也改不了。

所以在 Plan 阶段,Agent 可以做这些事:

Read:读取关键文件
Grep:搜索已有模式
Glob:发现项目结构
LS:理解目录布局
受限 Bash:在允许的版本和策略下做探索性观察
AskUserQuestion:澄清方案选择
ExitPlanMode:提交计划等待审批

但它不应该做这些事:

Edit / Write:修改文件
NotebookEdit:改 notebook
会产生副作用的 Bash / 网络 / 外部写入
继续派生会造成审批混乱的子流程

这就是 Plan Mode 和普通”让模型先想想”的根本区别。普通 prompt 只能要求模型自觉。Plan Mode 把工具面也收窄了。

这里要补一个边界:不同 Claude Code 文档和版本对 Plan Mode 下的 Bash 表述并不完全一样。有的强调”可以运行 shell 命令做探索”,有的概括为”不执行命令”。更稳妥的理解是:Plan Mode 的核心不是”Bash 这个工具名一定出现或一定消失”,而是不能产生修改性副作用。能否调用某类命令,要看当前版本、权限策略和工具过滤结果。

(生产环境里别假设 Bash 一定被禁用。看实际工具过滤名单更靠谱。)

新源码里的 Plan agent 提示词能很好地校正这个误解:它允许 lsgit statusgit loggit difffindcatheadtail 这类只读探索命令,同时明确禁止 mkdirtouchrmcpmvgit addgit commitnpm installpip install 这类会改变工作区或环境的命令。

所以 Plan mode 更准确的边界是:

探索可以继续。
改变必须等批准。

它通过工具自己的 checkPermissions、权限链、Plan agent 提示词和退出审批共同约束副作用,而不是靠一个“禁用 Bash”的单点规则。

四、Plan subagent:把研究上下文隔离出去

Claude Code 的公开文档还补了一层很重要的设计:Plan Mode 期间,如果 Claude 需要理解代码库,它可以把研究工作委派给内置的 Plan subagent。

这个 Plan subagent 的特点是:

  • 继承主会话模型;
  • 使用独立上下文窗口;
  • 只能使用只读工具;
  • 主要负责在计划前收集代码库信息;
  • 不能继续无限制地产生子代理。

这解决了另一个 Agent 系统常见问题:上下文污染

如果主线程自己把所有搜索、阅读、失败路径、无关文件内容都塞进同一个上下文,计划还没写出来,主对话已经变得又长又乱。Plan subagent 相当于一个研究员:它去代码库里查资料,最后把结论交回主线程。主线程保留更干净的视野,用来和用户对齐最终方案。

可以把它理解成:

主 Agent:负责目标理解、用户沟通、最终计划合成
Plan subagent:负责只读研究、证据收集、模式发现
权限系统:负责保证研究阶段不产生副作用

这个结构很克制。Claude Code 没有把 Plan 做成一个神秘的中央规划器,也没有公开一个固定的 plan AST(抽象语法树,一种结构化的计划表示形式)。它更像是:

主线程负责决策,Plan subagent 负责调查,权限系统负责护栏。

源码上还要区分两件事:主会话 Plan Mode 是权限状态转换;内置 Plan agent 是一个 agent definition。后者的定义里会禁用 AgentExitPlanModeEditWriteNotebookEdit 等工具,让它更像只读研究员,而不是另一个可以继续分派和实现的执行者。

五、ExitPlanModeTool:退出 Plan 比进入 Plan 更复杂

进入 Plan Mode 主要是切到只读阶段。退出 Plan Mode 要处理的事情更多。

ExitPlanModeV2Tool 至少承担四类责任:

1. 接收 Agent 生成的计划内容
2. 把计划呈现给用户或 team lead 审批
3. 恢复进入 Plan 前保存的权限模式
4. 为后续执行、恢复和验证保存计划材料

最值得注意的是权限恢复。

假设进入 Plan 前系统处在 auto 模式(自动模式,Agent 可在无需逐条人工确认的情况下执行部分操作)。正常情况下,退出 Plan 后应该恢复 auto,这样用户批准计划后,Agent 可以继续按原来的自动化策略执行。

但这里有个时间窗口:

进入 Plan 时 auto 可用
-> Agent 在 Plan Mode 中研究了十几分钟
-> 期间 auto gate(自动模式的安全闸门,一种可在特定条件下关闭自动权限的保护机制)因安全策略或断路器被关闭
-> Agent 退出 Plan

如果系统不重新检查 auto 是否仍然可用,就可能把 Agent 恢复到一个已经不该使用的高权限模式。

所以 ExitPlanMode 的恢复逻辑会做防御:如果原模式是 auto,但当前 auto gate 已经关闭,就回退到更保守的 default 模式。

这个细节说明 Claude Code 的 Plan 不是”用户点一下继续”的薄 UI。它是和权限状态紧密绑定的运行时状态机。

还有一个容易忽略的事实:ExitPlanModeV2Tool 本身不是 read-only,因为它会把 plan 写到磁盘。这个写入是 Plan 工作流允许的受控写入,不等于 Plan 阶段可以修改项目业务文件。退出时它会读取输入中的 plan 或磁盘上的 plan,用户如果在审批 UI 里编辑了计划,还会把编辑后的内容写回文件,然后再恢复 prePlanMode 并清理这个临时字段。

普通主会话里,退出 Plan 需要用户交互确认;在 team 场景里,如果当前上下文是 teammate,退出请求可能不会弹本地 UI,而是写入 team lead 的 mailbox,形成 plan_approval_request,等待 lead 审批后才能继续实现。

六、计划不是临时文本,而是可恢复的执行凭据

Plan Mode 还有一个很工程化的设计:计划不能只存在于聊天气泡里。

用户已经花时间审批了计划,Agent 也花 token 做了研究。如果接下来会话崩溃、远程会话恢复、上下文压缩,计划丢了,执行阶段就失去了依据。

所以 Claude Code 会把计划作为可恢复材料处理。

默认情况下,计划文件目录是 ~/.claude/plans。如果 settings 配了 plansDirectory,源码会把它解析成项目根目录内的相对路径,并检查不能逃出项目根。计划文件路径由 session slug 决定:主会话是 {planSlug}.md,子代理场景会带上 -agent-{agentId} 后缀,避免不同执行上下文互相覆盖。

本地材料里提到,计划恢复大致有三层来源:

第一层:直接读取计划文件
第二层:从 transcript 的 file snapshot 恢复
第三层:从消息历史恢复计划内容

消息历史里又可能有多种线索:

ExitPlanMode 的 tool_use input
User message 里的 planContent 字段
auto-compact 保留下来的 plan_file_reference

背后的思想很简单:计划一旦被用户批准,就不再只是自然语言建议,而是后续执行阶段的契约。

执行阶段需要知道:

  • 当时批准的目标是什么;
  • 哪些步骤被列入范围;
  • 哪些文件预计会改;
  • 哪些风险已经被声明;
  • 如果恢复会话,应该从哪个计划继续。

这就是 Plan Mode 的第二层本质:它把计划从聊天内容升级成可恢复的运行时对象。

(我们自己做 Agent 系统时,很容易忽略这一点。计划存在消息历史里就以为万事大吉。结果上下文一截断,计划跟着消失,Agent 进入执行阶段后就开始自由发挥。)

恢复和 fork 也被纳入这个生命周期:resume 时会尝试从历史 log 找 slug、读原计划文件;如果远程环境里文件丢了,还会从 file snapshot 或 message history 里恢复内容并重写文件。fork session 时则会生成新的 slug,把原 plan 文件复制过去,避免 fork 后的计划修改覆盖原会话。

七、验证 Hook:执行者之外还需要一个”旁观者”

Plan Mode 的最后一环,是计划验证。

本地资料里提到 registerPlanVerificationHook:它会在 ExitPlanModeV2Tool 附近被注册,用来在执行后检查实际结果是否符合计划。

有个细节:它需要在 context clear 之后注册。因为清理上下文会移除已有 hooks,注册太早,验证逻辑反而会被清掉。

为什么要单独做验证?

因为执行 Agent 自己检查自己,很容易出现”我觉得我做完了”的偏差。它会沿着自己的实施路径解释结果,却不一定逐条对照原计划。

验证 Agent 或验证 hook 更像 code review:

读取原始计划
-> 查看执行后的变更
-> 对照每个计划项
-> 标出完成项、偏差项、遗漏项
-> 把验证结果反馈给主流程

这让 Plan 不只是”执行前的仪式”,而是贯穿执行后的检查基准。

八、从一次分页需求看完整链路

还是用”给 REST API 加分页”作为例子。

如果没有 Plan Mode,Agent 可能直接开始写:

新增 pagination.ts
修改 users route
改响应结构
跑测试

看起来很高效,但风险是它可能还没理解项目习惯。

有 Plan Mode 后,链路变成这样:

用户:给 REST API 加分页

Claude Code:
1. 判断这是多文件实现任务,进入 Plan Mode
2. 保存当前权限模式到 prePlanMode
3. 切换为 plan 权限,只允许只读探索
4. 搜索已有接口、响应类型、校验中间件、ORM 使用方式
5. 必要时委派 Plan subagent 做代码库研究
6. 比较 offset 和 cursor 两种方案
7. 用 ExitPlanMode 提交计划
8. 用户审批计划
9. 恢复原权限模式
10. 按计划改代码
11. 运行测试
12. 验证实际改动是否符合计划

真正有价值的不是”第 7 步写了一份计划”。而是前后所有机制形成了闭环:

只读研究保证不乱改
权限切换保证边界
用户审批保证对齐
计划持久化保证可恢复
验证 hook 保证不偏离

这才是 Plan Mode 的完整含义。

九、Plan Mode 和 Agent 协作、Sandbox 的关系

这篇在系列里应该接在 Agent 协作之后、Sandbox 之前看。

和 Agent 协作的关系:Plan subagent 是 Claude Code 内置子 Agent 分工的一部分。它不是通用执行者,而是专门服务于计划阶段的研究角色。通过独立上下文减少主线程污染,通过只读工具限制副作用。

和 Sandbox 的关系:Plan Mode 解决”什么时候允许做事”,Sandbox 解决”允许做事时能碰到哪里”。前者是阶段边界,后者是环境边界。

可以粗略这样分:

Plan Mode:执行前,先限制行为
Permission:工具调用时,决定 ask / allow / deny
Sandbox:命令和文件访问时,限制影响范围
Subagent:复杂任务中,隔离上下文与职责
Hook:生命周期节点上,补充检查与自动化

这些机制合起来,才让 Claude Code 不只是”一个会调用工具的模型”,而像一个真正的 Agent Harness。

十、如果自己实现 Plan,最小版本应该长什么样

如果不照抄 Claude Code,只抽象它的设计,一个最小可用 Plan 系统至少要有五件事。

第一,显式模式。

mode = normal | plan

不要只靠 prompt。运行时状态里必须知道当前是不是 Plan 阶段。

第二,只读工具集。

plan mode:
  allow: Read, Search, List, SafeInspect
  deny: Write, Edit, Delete, MutatingShell

第三,退出审批点。

ExitPlan(plan)
-> show plan to user
-> approve / reject / revise

第四,计划持久化。

plan.md
message.tool_use.input.plan
resume snapshot

至少要保证会话恢复后还能找到用户批准过的计划。

第五,执行后验证。

approved_plan + actual_diff + test_result
-> verification report

没有验证,Plan 很容易变成”写在前面的愿望清单”。

总结

Claude Code 的 Plan 功能不是简单的”生成计划”。它更像一个把 Agent 行为分阶段治理的控制面。

一句话总结:

Plan Mode 把”先理解,再行动”从模型自觉,升级成了运行时制度。

它的关键设计可以压缩成五个词:

只读探索
权限切换
子 Agent 研究
用户审批
可恢复验证

如果把 Claude Code 当成一个 Agent Harness 来看,Plan Mode 的意义就很清楚了:它不是为了让计划更好看,而是为了让复杂任务在动手前先对齐、在执行中可追踪、在失败后可恢复。

这也是现代 Coding Agent 和普通聊天机器人的分界线之一。