Claude Code 的计划模式
Plan Mode 解决的不是“让模型更礼貌地先问一句”,而是一个更硬的工程问题:在代码被修改之前,如何先让 Agent 探索代码库、写出可审阅计划、等待用户批准,然后再恢复写入能力。
它真正改变的是权限模式、工具可用性、计划持久化、附件提示词和退出恢复路径。
核心直觉
Plan Mode 不是提示词里的“请先计划”。它是一套权限状态机:进入时保存原权限模式并切到 plan,探索阶段限制写入,计划写入磁盘,退出时经用户审批,再恢复进入前的权限状态。
先看源码入口
| 源码定位点 | 关键符号 | 负责什么 |
|---|---|---|
restored-src/src/utils/permissions/permissionSetup.ts:1462-1492 | prepareContextForPlanMode() | 进入 plan 前保存 prePlanMode,处理 auto mode 交互 |
restored-src/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36-102 | EnterPlanModeTool | 模型主动进入计划模式的工具 |
restored-src/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts | ExitPlanModeV2Tool | 提交计划、显示审批、恢复权限 |
restored-src/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:357-403 | 退出时 setAppState(...) | 恢复 prePlanMode、清理 plan 状态、处理 auto 熔断 |
restored-src/src/bootstrap/state.ts:1349-1363 | handlePlanModeTransition() | 进入/退出 plan 时设置附件标志,防止重复通知 |
restored-src/src/utils/plans.ts:79-128 | getPlansDirectory()、getPlanFilePath() | 计划文件存储目录、路径穿越防御、主会话/子 agent 隔离 |
restored-src/src/utils/plans.ts:32-49 | getPlanSlug() | 每个 session 生成可读 plan slug |
restored-src/src/commands/plan/plan.tsx:64-121 | /plan 命令 | 用户手动进入、查看或打开计划文件 |
restored-src/src/utils/attachments.ts:1195-1241 | getPlanModeAttachments() | plan 模式工作流提示词的附件注入和节流 |
读 Plan Mode 不能只看 /plan 命令。真正的核心是:ToolPermissionContext.mode 如何切到 plan,prePlanMode 如何保存和恢复,以及 plan 文件如何从对话里独立出来。
总流程图
这张图里有两个关键状态:
mode: 'plan':权限系统进入计划模式,写入类动作被限制。prePlanMode:进入 plan 前的模式,用于退出时恢复。
进入 Plan Mode:prePlanMode 是可逆性的关键
Plan Mode 有两条进入路径:
| 入口 | 行为 |
|---|---|
模型调用 EnterPlanMode | 需要经过工具调用路径,适合模型判断需要先规划 |
用户输入 /plan | 直接进入计划模式,可带描述触发模型开始规划 |
两条路径最终都要准备权限上下文。原文给出的核心函数:
export function prepareContextForPlanMode(
context: ToolPermissionContext,
): ToolPermissionContext {
const currentMode = context.mode;
if (currentMode === 'plan') return context;
if (feature('TRANSCRIPT_CLASSIFIER')) {
const planAutoMode = shouldPlanUseAutoMode();
if (currentMode === 'auto') {
if (planAutoMode) {
return { ...context, prePlanMode: 'auto' };
}
// ... 关闭 auto mode 并恢复被 auto 剥离的权限
}
if (planAutoMode && currentMode !== 'bypassPermissions') {
autoModeStateModule?.setAutoModeActive(true);
return {
...stripDangerousPermissionsForAutoMode(context),
prePlanMode: currentMode,
};
}
}
return { ...context, prePlanMode: currentMode };
}
这段代码证明 Plan Mode 不是一个孤立布尔值。进入时必须保存当前模式:
当前 mode = default / acceptEdits / auto / ...
-> prepareContextForPlanMode()
-> context.prePlanMode = currentMode
-> permission update: mode = plan
prePlanMode 的作用是退出时恢复用户原来的权限意图。否则用户从 acceptEdits 进入 plan,退出后系统不知道应该回 default 还是 acceptEdits。
与 auto mode 的交互
上面代码里 TRANSCRIPT_CLASSIFIER 和 auto 分支说明:Plan Mode 还要处理自动权限模式。进入 plan 时可能剥离危险 allow 规则,退出时还要根据 gate 状态和熔断器决定能不能恢复 auto。
这体现一个边界:Plan Mode 可以暂存权限状态,但不能绕过权限系统的安全降级。
EnterPlanModeTool:模型能请求进入,但不能在任何地方进入
EnterPlanModeTool 的定义里有几个关键约束:
export const EnterPlanModeTool: Tool<InputSchema, Output> = buildTool({
name: ENTER_PLAN_MODE_TOOL_NAME,
shouldDefer: true,
isEnabled() {
if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
getAllowedChannels().length > 0) {
return false;
}
return true;
},
isConcurrencySafe() { return true },
isReadOnly() { return true },
async call(_input, context) {
if (context.agentId) {
throw new Error('EnterPlanMode tool cannot be used in agent contexts');
}
// ... 执行模式切换
},
});
这段代码证明了四个设计点:
| 约束 | 源码证据 | 说明 |
|---|---|---|
| 延迟加载 | shouldDefer: true | 不是每次初始提示都完整暴露 schema |
| 只读和并发安全 | isReadOnly() { return true }、isConcurrencySafe() { return true } | 进入计划模式本身不改业务文件 |
| 子 agent 禁用 | if (context.agentId) throw ... | 子 agent 不该自行改变主会话权限模式 |
| Channels 活跃时禁用 | getAllowedChannels().length > 0 | 远程消息渠道里用户可能看不到审批 UI,进入 plan 会变成陷阱 |
容易误解
EnterPlanMode 是工具,但它不是普通业务工具。它改变的是会话权限模式,所以必须限制 agent 上下文和远程 channel 场景。
退出 Plan Mode:审批和权限恢复比进入更复杂
退出由 ExitPlanModeV2Tool 处理。原文把路径分成三类:
| 调用者 | 当前状态 | 行为 |
|---|---|---|
| 普通主会话 | 当前是 plan | 显示审批对话框,批准后恢复 prePlanMode |
| 普通主会话 | 当前不是 plan | 拒绝:不在 plan 模式中 |
| Teammate 且需要计划审批 | plan 文件存在 | 发送 plan_approval_request 给 leader,等待审批 |
| Teammate 自愿 plan | 无强制审批 | 可直接退出 |
退出时最关键的是这段状态恢复逻辑:
context.setAppState(prev => {
if (prev.toolPermissionContext.mode !== 'plan') return prev;
setHasExitedPlanMode(true);
setNeedsPlanModeExitAttachment(true);
let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default';
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (restoreMode === 'auto' &&
!(permissionSetupModule?.isAutoModeGateEnabled() ?? false)) {
restoreMode = 'default';
}
// ... auto mode 激活状态同步
}
const restoringToAuto = restoreMode === 'auto';
if (restoringToAuto) {
baseContext = permissionSetupModule?.stripDangerousPermissionsForAutoMode(baseContext);
} else if (prev.toolPermissionContext.strippedDangerousRules) {
baseContext = permissionSetupModule?.restoreDangerousPermissions(baseContext);
}
return {
...prev,
toolPermissionContext: {
...baseContext,
mode: restoreMode,
prePlanMode: undefined,
},
};
});
这段代码证明退出不是简单 mode = prePlanMode。它还会:
- 设置
hasExitedPlanMode。 - 设置
needsPlanModeExitAttachment,下一轮注入退出提示。 - 如果原来是
auto但 auto gate 已关闭,恢复到default。 - 如果恢复到 auto,继续剥离危险权限。
- 如果不恢复 auto,则恢复此前被剥离的危险规则。
- 清理
prePlanMode。
核心直觉
Plan Mode 的退出逻辑带有“熔断器防御”:进入前是 auto,不代表退出后一定能回 auto。中途如果安全门控变了,系统宁愿回到 default。
状态转换防抖:进入和退出附件不能乱发
handlePlanModeTransition() 管的是进入/退出 plan 时的通知标志:
export function handlePlanModeTransition(fromMode: string, toMode: string): void {
if (toMode === 'plan' && fromMode !== 'plan') {
STATE.needsPlanModeExitAttachment = false;
}
if (fromMode === 'plan' && toMode !== 'plan') {
STATE.needsPlanModeExitAttachment = true;
}
}
这段代码证明一个细节:用户可能快速切换模式,系统要避免同时发送“进入 plan”和“退出 plan”的混乱提醒。进入 plan 时清掉待发的退出附件,离开 plan 时再标记需要发送退出附件。
计划文件:把计划从对话里拿出来
Plan Mode 的计划不是只存在聊天记录里,而是写入磁盘。核心路径函数:
export const getPlansDirectory = memoize(function getPlansDirectory(): string {
const settings = getInitialSettings();
const settingsDir = settings.plansDirectory;
let plansPath: string;
if (settingsDir) {
const cwd = getCwd();
const resolved = resolve(cwd, settingsDir);
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
logError(new Error(`plansDirectory must be within project root: ${settingsDir}`));
plansPath = join(getClaudeConfigHomeDir(), 'plans');
} else {
plansPath = resolved;
}
} else {
plansPath = join(getClaudeConfigHomeDir(), 'plans');
}
// ...
});
export function getPlanFilePath(agentId?: AgentId): string {
const planSlug = getPlanSlug(getSessionId());
if (!agentId) {
return join(getPlansDirectory(), `${planSlug}.md`);
}
return join(getPlansDirectory(), `${planSlug}-agent-${agentId}.md`);
}
这段代码证明 plan 文件有明确的存储策略:
| 设计 | 源码体现 | 作用 |
|---|---|---|
| 默认全局目录 | join(getClaudeConfigHomeDir(), 'plans') | 不污染项目仓库 |
| 可配置目录 | settings.plansDirectory | 团队可放到项目内,比如 .claude/plans/ |
| 路径穿越防御 | resolved.startsWith(cwd + sep) | 防止配置逃逸出项目根 |
| 主会话文件 | ${planSlug}.md | 一个会话一个计划 |
| 子 agent 文件 | ${planSlug}-agent-${agentId}.md | 子 agent 计划互相隔离 |
memoize() | getPlansDirectory 记忆化 | 避免反复触发目录创建和系统调用 |
getPlanSlug() 再生成每个 session 的可读 slug:
export function getPlanSlug(sessionId?: SessionId): string {
const id = sessionId ?? getSessionId();
const cache = getPlanSlugCache();
let slug = cache.get(id);
if (!slug) {
const plansDir = getPlansDirectory();
for (let i = 0; i < MAX_SLUG_RETRIES; i++) {
slug = generateWordSlug();
const filePath = join(plansDir, `${slug}.md`);
if (!getFsImplementation().existsSync(filePath)) {
break;
}
}
cache.set(id, slug!);
}
return slug!;
}
这里不是 UUID,而是 adjective-noun 风格词组 slug。目的很直接:计划文件需要给人看、给人找、给人打开。
读源码抓手
Plan Mode 的“可审阅”不是靠聊天记录,而是靠 plan 文件。读源码时一定要追 getPlanFilePath(),否则会误以为计划只是普通 assistant 文本。
/plan 命令:用户入口如何接到权限状态机
用户命令在 commands/plan/plan.tsx:
export async function call(onDone, context, args) {
const currentMode = appState.toolPermissionContext.mode;
if (currentMode !== 'plan') {
handlePlanModeTransition(currentMode, 'plan');
setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prepareContextForPlanMode(prev.toolPermissionContext),
{ type: 'setMode', mode: 'plan', destination: 'session' },
),
}));
const description = args.trim();
if (description && description !== 'open') {
onDone('Enabled plan mode', { shouldQuery: true });
} else {
onDone('Enabled plan mode');
}
return null;
}
if (argList[0] === 'open') {
const result = await editFileInEditor(planPath);
// ...
}
}
这段代码证明 /plan 命令有几种行为:
| 命令 | 行为 |
|---|---|
/plan,当前不在 plan | 进入 plan 模式 |
/plan <描述> | 进入 plan 模式,并 shouldQuery: true 触发模型开始规划 |
/plan,当前已在 plan | 显示当前计划内容和路径 |
/plan open | 在外部编辑器中打开计划文件 |
用户入口不是绕过状态机,而是同样调用 prepareContextForPlanMode() 和 applyPermissionUpdate()。
计划附件:提示词工作流也要节流
进入 Plan Mode 后,系统会通过附件消息把工作流提醒注入给模型。原文提到三种附件:
| 附件类型 | 触发时机 | 作用 |
|---|---|---|
plan_mode | plan 模式中每隔若干人类轮次注入 | 提醒模型遵守探索、计划、审批流程 |
plan_mode_reentry | 退出后再次进入 plan | 提醒模型先检查已有计划 |
plan_mode_exit | 退出 plan 后第一轮 | 告诉模型现在可以开始实现 |
核心函数:
function getPlanModeAttachments(messages, toolUseContext) {
const { turnCount, foundPlanModeAttachment } =
getPlanModeAttachmentTurnCount(messages);
if (foundPlanModeAttachment &&
turnCount < PLAN_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS) {
return [];
}
const attachmentCount = countPlanModeAttachmentsSinceLastExit(messages);
const reminderType = attachmentCount %
PLAN_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS === 1
? 'full' : 'sparse';
attachments.push({ type: 'plan_mode', reminderType, isSubAgent, planFilePath, planExists });
return attachments;
}
这段代码证明附件注入不是每轮都重复完整提示词。它会:
- 检查距离上次 plan 附件的人类轮次数。
- 未达到间隔就跳过。
- 按计数决定注入
full还是sparsereminder。 - 附带
planFilePath和planExists,让模型知道计划文件状态。
核心直觉
Plan Mode 同时靠硬权限和软提示词。权限负责“不能写”,附件负责“应该如何规划”。两者缺一不可。
它如何改造 Agent Loop
普通 Agent Loop 往往是:
读一点 -> 改一点 -> 跑一下 -> 再改一点
Plan Mode 把它改成:
进入 plan -> 只读探索 -> 写计划文件 -> ExitPlanMode 提交审批 -> 恢复权限 -> 执行实现
这条链路的关键是把“理解是否正确”的检查点放在写入之前。模型可以先探索和表达计划,但不能把未确认的理解直接落成代码。
状态和数据结构
| 结构 | 字段或值 | 控制什么 |
|---|---|---|
ToolPermissionContext | mode: 'plan' | 当前处于计划模式 |
ToolPermissionContext | prePlanMode | 退出后恢复的权限模式 |
ToolPermissionContext | strippedDangerousRules | auto/plan 交互时被剥离的危险规则 |
AppState | hasExitedPlanMode | 标记已经退出过 plan |
AppState/bootstrap STATE | needsPlanModeExitAttachment | 下一轮是否注入退出附件 |
| Plan file | ${planSlug}.md | 主会话计划文件 |
| Plan file | ${planSlug}-agent-${agentId}.md | 子 agent 计划文件 |
| Attachment | plan_mode、plan_mode_reentry、plan_mode_exit | 给模型的计划工作流提醒 |
| Teammate protocol | plan_approval_request | 多 agent 场景中的计划审批 |
设计取舍
| 取舍点 | 源码体现 | 工程判断 |
|---|---|---|
| 权限模式而非纯提示词 | mode = 'plan'、prePlanMode | 不能只靠模型自觉不写,必须由权限系统限制 |
| 可逆状态切换 | prePlanMode 保存进入前模式 | Plan Mode 是临时阶段,退出要恢复用户原意图 |
| auto 熔断防御 | auto gate 关闭时恢复到 default | 不让 plan 退出绕过安全降级 |
| 子 agent 不能随意进入 | context.agentId 抛错 | 权限模式切换属于主会话控制权 |
| Channels 禁用 | allowed channels 存在时 isEnabled() = false | 避免用户看不到审批 UI 而被困在 plan |
| plan 文件持久化 | getPlanFilePath() | 计划可审阅、可编辑、可跨压缩保留 |
| 路径穿越防御 | resolved.startsWith(cwd + sep) | 项目内 plansDirectory 不允许逃逸 |
| 附件节流 | full/sparse reminder | 提醒模型遵守流程,同时控制上下文开销 |
读源码抓手
- 先看
prepareContextForPlanMode(),确认prePlanMode如何保存。 - 再看
EnterPlanModeTool,重点看shouldDefer、agentId禁止、channels 禁用。 - 读
ExitPlanModeV2Tool的setAppState恢复逻辑,尤其是 auto gate 熔断。 - 看
handlePlanModeTransition(),理解进入/退出附件标志如何防抖。 - 进入
utils/plans.ts,追getPlansDirectory()、getPlanFilePath()、getPlanSlug()。 - 看
/plan命令,确认用户入口如何调用同一套权限状态机。 - 最后读
getPlanModeAttachments(),理解 plan 工作流提示词如何注入和节流。
小结
- Plan Mode 是权限状态机,不是普通“先计划”提示词。 - prePlanMode 让进入和退出成为可逆操作。 - EnterPlanMode 受 agent 上下文和 channels 限制,避免权限控制权跑偏。 - ExitPlanMode 负责审批和权限恢复,并带有 auto mode 熔断防御。 - plan 文件把意图对齐从聊天记录里拿出来,变成可审阅、可编辑、可恢复的工程对象。