Claude Code 源码解剖

Claude Code 的计划模式

从 EnterPlanMode、ExitPlanMode、prepareContextForPlanMode、plan 文件、附件注入和权限恢复,拆解 Plan Mode 如何把探索、计划、审批和执行拆成状态机。

第 5 章 15 分钟

Claude Code 的计划模式

Plan Mode 解决的不是“让模型更礼貌地先问一句”,而是一个更硬的工程问题:在代码被修改之前,如何先让 Agent 探索代码库、写出可审阅计划、等待用户批准,然后再恢复写入能力。

它真正改变的是权限模式、工具可用性、计划持久化、附件提示词和退出恢复路径。

核心直觉

Plan Mode 不是提示词里的“请先计划”。它是一套权限状态机:进入时保存原权限模式并切到 plan,探索阶段限制写入,计划写入磁盘,退出时经用户审批,再恢复进入前的权限状态。

先看源码入口

源码定位点关键符号负责什么
restored-src/src/utils/permissions/permissionSetup.ts:1462-1492prepareContextForPlanMode()进入 plan 前保存 prePlanMode,处理 auto mode 交互
restored-src/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36-102EnterPlanModeTool模型主动进入计划模式的工具
restored-src/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.tsExitPlanModeV2Tool提交计划、显示审批、恢复权限
restored-src/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:357-403退出时 setAppState(...)恢复 prePlanMode、清理 plan 状态、处理 auto 熔断
restored-src/src/bootstrap/state.ts:1349-1363handlePlanModeTransition()进入/退出 plan 时设置附件标志,防止重复通知
restored-src/src/utils/plans.ts:79-128getPlansDirectory()getPlanFilePath()计划文件存储目录、路径穿越防御、主会话/子 agent 隔离
restored-src/src/utils/plans.ts:32-49getPlanSlug()每个 session 生成可读 plan slug
restored-src/src/commands/plan/plan.tsx:64-121/plan 命令用户手动进入、查看或打开计划文件
restored-src/src/utils/attachments.ts:1195-1241getPlanModeAttachments()plan 模式工作流提示词的附件注入和节流

读 Plan Mode 不能只看 /plan 命令。真正的核心是:ToolPermissionContext.mode 如何切到 planprePlanMode 如何保存和恢复,以及 plan 文件如何从对话里独立出来。

总流程图

flowchart TB A["用户 /plan 或模型调用 EnterPlanMode"] --> B["prepareContextForPlanMode(context)"] B --> C["保存 prePlanMode<br/>mode -> plan"] C --> D["Plan Mode 附件注入<br/>plan_mode full / sparse reminder"] D --> E["只读探索<br/>Read / Grep / Glob / Task"] E --> F["写入 plan 文件<br/>getPlanFilePath(sessionId/agentId)"] F --> G["模型调用 ExitPlanMode(plan)"] G --> H{"审批结果"} H -->|批准| I["恢复 prePlanMode<br/>清理 prePlanMode<br/>设置 plan exit attachment"] H -->|拒绝| J["保持 plan 模式<br/>继续探索和修改计划"] H -->|Teammate 需要审批| K["发送 plan_approval_request<br/>等待 leader"] I --> L["plan_mode_exit 附件<br/>提示现在可以执行"] L --> M["进入写入实现阶段"]

这张图里有两个关键状态:

  • mode: &#39;plan&#39;:权限系统进入计划模式,写入类动作被限制。
  • 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_CLASSIFIERauto 分支说明: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 &gt; 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(), &#39;plans&#39;)不污染项目仓库
可配置目录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 &lt;描述&gt;进入 plan 模式,并 shouldQuery: true 触发模型开始规划
/plan,当前已在 plan显示当前计划内容和路径
/plan open在外部编辑器中打开计划文件

用户入口不是绕过状态机,而是同样调用 prepareContextForPlanMode()applyPermissionUpdate()

计划附件:提示词工作流也要节流

进入 Plan Mode 后,系统会通过附件消息把工作流提醒注入给模型。原文提到三种附件:

附件类型触发时机作用
plan_modeplan 模式中每隔若干人类轮次注入提醒模型遵守探索、计划、审批流程
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 还是 sparse reminder。
  • 附带 planFilePathplanExists,让模型知道计划文件状态。

核心直觉

Plan Mode 同时靠硬权限和软提示词。权限负责“不能写”,附件负责“应该如何规划”。两者缺一不可。

它如何改造 Agent Loop

普通 Agent Loop 往往是:

读一点 -> 改一点 -> 跑一下 -> 再改一点

Plan Mode 把它改成:

进入 plan -> 只读探索 -> 写计划文件 -> ExitPlanMode 提交审批 -> 恢复权限 -> 执行实现
sequenceDiagram participant U as User participant R as REPL/AppState participant L as Agent Loop participant P as Plan File participant W as Workspace U->>R: /plan 重构认证模块 R->>R: prepareContextForPlanMode() R->>L: mode = plan, prePlanMode = previous L->>W: Read / Grep / Glob 探索 L->>P: 写入计划 Markdown L->>U: ExitPlanMode 审批 U->>R: 批准 R->>R: restore prePlanMode R->>L: plan_mode_exit 附件 L->>W: 开始真实编辑和验证

这条链路的关键是把“理解是否正确”的检查点放在写入之前。模型可以先探索和表达计划,但不能把未确认的理解直接落成代码。

状态和数据结构

结构字段或值控制什么
ToolPermissionContextmode: &#39;plan&#39;当前处于计划模式
ToolPermissionContextprePlanMode退出后恢复的权限模式
ToolPermissionContextstrippedDangerousRulesauto/plan 交互时被剥离的危险规则
AppStatehasExitedPlanMode标记已经退出过 plan
AppState/bootstrap STATEneedsPlanModeExitAttachment下一轮是否注入退出附件
Plan file${planSlug}.md主会话计划文件
Plan file${planSlug}-agent-${agentId}.md子 agent 计划文件
Attachmentplan_modeplan_mode_reentryplan_mode_exit给模型的计划工作流提醒
Teammate protocolplan_approval_request多 agent 场景中的计划审批

设计取舍

取舍点源码体现工程判断
权限模式而非纯提示词mode = &#39;plan&#39;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提醒模型遵守流程,同时控制上下文开销

读源码抓手

  1. 先看 prepareContextForPlanMode(),确认 prePlanMode 如何保存。
  2. 再看 EnterPlanModeTool,重点看 shouldDeferagentId 禁止、channels 禁用。
  3. ExitPlanModeV2ToolsetAppState 恢复逻辑,尤其是 auto gate 熔断。
  4. handlePlanModeTransition(),理解进入/退出附件标志如何防抖。
  5. 进入 utils/plans.ts,追 getPlansDirectory()getPlanFilePath()getPlanSlug()
  6. /plan 命令,确认用户入口如何调用同一套权限状态机。
  7. 最后读 getPlanModeAttachments(),理解 plan 工作流提示词如何注入和节流。

小结

- Plan Mode 是权限状态机,不是普通“先计划”提示词。 - prePlanMode 让进入和退出成为可逆操作。 - EnterPlanMode 受 agent 上下文和 channels 限制,避免权限控制权跑偏。 - ExitPlanMode 负责审批和权限恢复,并带有 auto mode 熔断防御。 - plan 文件把意图对齐从聊天记录里拿出来,变成可审阅、可编辑、可恢复的工程对象。