Claude Code 源码:工具 Plan 模式

2 阅读34分钟

Claude Code 源码:工具 Plan 模式

导航

  • 🎯 什么是 Plan 模式?核心问题 — 为什么需要"先规划再执行"
  • 🔄 完整流程是什么?生命周期 — 从进入到退出的 5 个阶段
  • 🤖 模型如何判断?触发机制 — 提示词如何塑造模型决策
  • 🔒 如何限制写操作?工具权限 — isReadOnly 的判断逻辑
  • 📝 Plan 文件在哪?文件持久化 — plan 文件的读写与恢复

目录

第一部分:概念理解

第二部分:使用流程

第三部分:实现机制

第四部分:FAQ


极限场景:重构任务中的"先看后动"

想象这样一个时刻:

用户说"把整个项目的认证系统从 JWT 迁移到 OAuth2,涉及 15 个文件"。

如果模型直接开始改代码,可能会:

  • 第 3 个文件改完才发现架构方向错了
  • 第 8 个文件发现依赖关系没理清
  • 第 12 个文件发现测试策略不对,前面的改动都要推倒重来

Plan 模式的价值:强制模型在动手前完成三件事:

① 探索阶段(只读)
   - 用 Grep 找出所有相关文件
   - 用 Read 理解现有架构
   - 用 Glob 确认依赖关系

② 设计阶段(只读)
   - 写 plan 文件:迁移步骤、风险点、测试策略
   - 用 AskUserQuestion 确认不确定的设计决策

③ 审批阶段(用户确认)
   - ExitPlanMode 展示 plan 给用户
   - 用户批准后才切换到实现模式

这个流程的关键:模型在 plan 模式下无法执行任何写操作(除了写 plan 文件本身)。这是架构约束,不是提示词建议。


Plan 模式解决什么问题

问题 1:模型的"边做边想"倾向

LLM 的推理是流式的——生成第一个 token 时,它还不知道第 1000 个 token 会是什么。这导致模型在复杂任务中容易"边做边想":

模型思路:
"先改 auth.ts... 哦等等,这里还依赖 session.ts... 
 改完 session.ts 发现还要改 middleware.ts...
 改到一半发现最开始的 auth.ts 改错了..."

Plan 模式的约束:在实现前强制完成全局分析,把"边做边想"变成"先想后做"。

问题 2:用户无法提前审查方案

传统 Agent 流程:

用户: "重构认证系统"
模型: [直接开始改代码]
      [改了 5 个文件后]
      "我发现这个方案有问题,需要推倒重来"
用户: 😱 (已经浪费了 10 分钟和几千 tokens)

Plan 模式流程:

用户: "重构认证系统"
模型: [EnterPlanMode]
      [探索代码库,写 plan 文件]
      [ExitPlanMode,展示 plan]
用户: "这个方案不对,应该先迁移 API 层再迁移 UI"
模型: [修改 plan]
      [用户批准后才开始实现]

核心价值:把"事后返工"变成"事前对齐"。

Plan 模式 vs Plan Agent:两种规划方式

Claude Code 提供了两种规划机制,容易混淆但用途不同:

维度Plan 模式(EnterPlanMode)Plan Agent(subagent_type="Plan")
触发方式主会话调用 EnterPlanMode 工具主会话调用 Agent 工具,指定 subagent_type="Plan"
执行主体主会话模型(切换到只读模式)独立的子 Agent(新的推理上下文)
上下文继承主会话的完整对话历史只接收任务描述 + 相关文件(轻量上下文)
工具限制通过 isReadOnly 检查禁用写工具通过 disallowedTools 列表禁用写工具
Plan 文件主会话自己写 plan 文件Plan Agent 返回 plan 文本(不写文件)
退出方式ExitPlanMode 恢复到原模式Agent 任务完成自动退出
适用场景主会话需要深度探索后再实现主会话需要快速获取规划建议

Plan 模式的典型流程

用户: "重构认证系统"
主会话: [调用 EnterPlanMode]
       [切换到只读模式]
       [探索代码库 20 分钟]
       [写 plan 文件到 ~/.claude/plans/happy-cat.md]
       [调用 ExitPlanMode]
       [用户批准 plan]
       [恢复到 default 模式]
       [开始实现]

Plan Agent 的典型流程

用户: "重构认证系统"
主会话: [调用 Agent 工具]
       {
         "subagent_type": "Plan",
         "prompt": "设计认证系统重构方案,当前使用 JWT,目标迁移到 OAuth2"
       }
       [Plan Agent 启动,独立上下文]
       [Plan Agent 探索代码库]
       [Plan Agent 返回 plan 文本]
主会话: [收到 plan 文本]
       [基于 plan 开始实现]

核心区别

  1. 上下文隔离:Plan 模式是主会话的"状态切换",Plan Agent 是"外包给子 Agent"
  2. Plan 文件:Plan 模式会持久化 plan 文件(用于审批流程),Plan Agent 只返回文本(用于快速参考)
  3. 用户交互:Plan 模式需要用户批准 plan(ExitPlanMode 触发审批),Plan Agent 不需要审批(主会话自行决策)

何时用 Plan 模式?

  • 任务复杂,需要主会话深度参与探索
  • 需要用户审批 plan 后再实现
  • 需要在 plan 和实现之间保持完整上下文

何时用 Plan Agent?

  • 任务明确,只需要快速获取规划建议
  • 主会话可以自行决策是否采纳 plan
  • 希望隔离规划过程,避免污染主会话上下文

极限场景:重构任务中的"先看后动"

想象这样一个时刻:

用户说"把整个项目的认证系统从 JWT 迁移到 OAuth2,涉及 15 个文件"。

如果模型直接开始改代码,可能会:

  • 第 3 个文件改完才发现架构方向错了
  • 第 8 个文件发现依赖关系没理清
  • 第 12 个文件发现测试策略不对,前面的改动都要推倒重来

Plan 模式的价值:强制模型在动手前完成三件事:

① 探索阶段(只读)
   - 用 Grep 找出所有相关文件
   - 用 Read 理解现有架构
   - 用 Glob 确认依赖关系

② 设计阶段(只读)
   - 写 plan 文件:迁移步骤、风险点、测试策略
   - 用 AskUserQuestion 确认不确定的设计决策

③ 审批阶段(用户确认)
   - ExitPlanMode 展示 plan 给用户
   - 用户批准后才切换到实现模式

这个流程的关键:模型在 plan 模式下无法执行任何写操作(除了写 plan 文件本身)。这是架构约束,不是提示词建议。


Plan 模式解决什么问题

问题 1:模型的"边做边想"倾向

LLM 的推理是流式的——生成第一个 token 时,它还不知道第 1000 个 token 会是什么。这导致模型在复杂任务中容易"边做边想":

模型思路:
"先改 auth.ts... 哦等等,这里还依赖 session.ts... 
 改完 session.ts 发现还要改 middleware.ts...
 改到一半发现最开始的 auth.ts 改错了..."

Plan 模式的约束:在实现前强制完成全局分析,把"边做边想"变成"先想后做"。

问题 2:用户无法提前审查方案

传统 Agent 流程:

用户: "重构认证系统"
模型: [直接开始改代码]
      [改了 5 个文件后]
      "我发现这个方案有问题,需要推倒重来"
用户: 😱 (已经浪费了 10 分钟和几千 tokens)

Plan 模式流程:

用户: "重构认证系统"
模型: [EnterPlanMode]
      [探索代码库,写 plan 文件]
      [ExitPlanMode,展示 plan]
用户: "这个方案不对,应该先迁移 API 层再迁移 UI"
模型: [修改 plan]
      [用户批准后才开始实现]

核心价值:把"事后返工"变成"事前对齐"。


Plan 模式 vs Plan Agent

Claude Code 提供了两种规划机制,容易混淆但用途不同:

维度Plan 模式(EnterPlanMode)Plan Agent(subagent_type="Plan")
触发方式主会话调用 EnterPlanMode 工具主会话调用 Agent 工具,指定 subagent_type="Plan"
执行主体主会话模型(切换到只读模式)独立的子 Agent(新的推理上下文)
上下文继承主会话的完整对话历史只接收任务描述 + 相关文件(轻量上下文)
工具限制通过 isReadOnly 检查禁用写工具通过 disallowedTools 列表禁用写工具
Plan 文件主会话自己写 plan 文件Plan Agent 返回 plan 文本(不写文件)
退出方式ExitPlanMode 恢复到原模式Agent 任务完成自动退出
适用场景主会话需要深度探索后再实现主会话需要快速获取规划建议

Plan 模式的典型流程

用户: "重构认证系统"
主会话: [调用 EnterPlanMode]
       [切换到只读模式]
       [探索代码库 20 分钟]
       [写 plan 文件到 ~/.claude/plans/happy-cat.md]
       [调用 ExitPlanMode]
       [用户批准 plan]
       [恢复到 default 模式]
       [开始实现]

Plan Agent 的典型流程

用户: "重构认证系统"
主会话: [调用 Agent 工具]
       {
         "subagent_type": "Plan",
         "prompt": "设计认证系统重构方案,当前使用 JWT,目标迁移到 OAuth2"
       }
       [Plan Agent 启动,独立上下文]
       [Plan Agent 探索代码库]
       [Plan Agent 返回 plan 文本]
主会话: [收到 plan 文本]
       [基于 plan 开始实现]

核心区别

  1. 上下文隔离:Plan 模式是主会话的"状态切换",Plan Agent 是"外包给子 Agent"
  2. Plan 文件:Plan 模式会持久化 plan 文件(用于审批流程),Plan Agent 只返回文本(用于快速参考)
  3. 用户交互:Plan 模式需要用户批准 plan(ExitPlanMode 触发审批),Plan Agent 不需要审批(主会话自行决策)

何时用 Plan 模式?

  • 任务复杂,需要主会话深度参与探索
  • 需要用户审批 plan 后再实现
  • 需要在 plan 和实现之间保持完整上下文

何时用 Plan Agent?

  • 任务明确,只需要快速获取规划建议
  • 主会话可以自行决策是否采纳 plan
  • 希望隔离规划过程,避免污染主会话上下文

Plan 模式的完整生命周期

让我们先看完整流程,再深入每个环节的细节。

阶段 1:进入 Plan 模式

触发方式

  • 模型主动判断:根据提示词中的条件(新功能、多文件、架构决策等),模型调用 EnterPlanMode 工具
  • 用户强制切换:用户输入 /mode plan 命令

状态变化

AppState.toolPermissionContext.mode = 'plan'
AppState.toolPermissionContext.prePlanMode = 'default'  // 保存进入前的模式

模型收到的反馈

Entered plan mode. You should now focus on exploring the codebase...

In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval

Remember: DO NOT write or edit any files yet.

阶段 2:探索代码库(只读)

模型使用只读工具探索:

模型的探索路径:
① Grep 搜索相关文件
   grep "authentication" --type ts
   → 找到 auth.ts, session.ts, middleware.ts

② Read 理解现有架构
   Read auth.ts → 理解当前 JWT 实现
   Read session.ts → 理解会话管理
   Read middleware.ts → 理解认证中间件

③ Glob 确认依赖关系
   glob "**/*auth*.ts" → 找到所有认证相关文件

权限约束

  • ✅ 可用:Read、Grep、Glob、EnterPlanMode
  • ❌ 禁用:Edit、Write、Bash

如果模型尝试调用 Edit,会收到错误:

Edit is not allowed in plan mode. Plan mode is read-only.

阶段 3:编写 Plan 文件

模型基于探索结果,调用 Write 工具写 plan 文件:

{
  "name": "Write",
  "input": {
    "file_path": "/Users/user/.claude/plans/happy-cat.md",
    "content": `# JWT to OAuth2 Migration Plan

## Overview
Migrate authentication from JWT to OAuth2...

## Current State Analysis
- auth.ts: JWT token generation and validation
- session.ts: In-memory session storage
- middleware.ts: JWT verification middleware

## Proposed Approach
1. Add OAuth2 provider configuration
2. Implement OAuth2 callback handler
3. Update middleware to support both JWT and OAuth2
4. Migrate existing users gradually

## Implementation Steps
1. Install oauth2 library (Step 1)
2. Add OAuth2 config to .env (Step 2)
3. Create oauth2.ts with provider logic (Step 3)
...

## Risks and Considerations
- Existing JWT tokens need grace period
- Session storage may need Redis for OAuth2

## Testing Strategy
- Unit tests for OAuth2 flow
- Integration tests for mixed auth
- Manual testing with real OAuth2 provider
`
  }
}

Plan 文件的典型结构

  • Overview:任务的整体描述和目标
  • Current State Analysis:通过探索得到的现有架构分析
  • Proposed Approach:选择的实现方案和理由
  • Implementation Steps:具体的步骤(带文件名和改动)
  • Risks and Considerations:潜在风险和需要注意的点
  • Testing Strategy:如何验证实现的正确性

阶段 4:退出并审批

模型调用 ExitPlanMode 提交 plan:

{
  "name": "ExitPlanMode",
  "input": {}
}

系统行为

  1. 读取 plan 文件内容
  2. 恢复 modeprePlanMode(如 'default'
  3. 把 plan 内容展示给用户

模型收到的反馈

User has approved your plan. You can now start coding.

Your plan has been saved to: /Users/user/.claude/plans/happy-cat.md

## Approved Plan:
[plan 内容回显]

用户审批流程

  • 批准:模型收到上述反馈,开始实现
  • 拒绝:用户提出修改意见,模型重新进入 plan 模式修改 plan

阶段 5:开始实现

模型恢复到 default 模式,可以调用所有工具:

模型的实现路径:
① 参考 plan 文件中的步骤
② 调用 Bash 安装依赖: npm install oauth2-library
③ 调用 Write 创建新文件: oauth2.ts
④ 调用 Edit 修改现有文件: middleware.ts
⑤ 调用 Bash 运行测试: npm test

完整流程图

┌─────────────────────────────────────────────────────────┐
│                  Plan 模式完整生命周期                    │
│                                                         │
│  ① 进入 Plan 模式                                        │
│     EnterPlanMode / /mode plan                         │
│     ↓                                                  │
│  ② 探索代码库(只读)                                     │
│     Grep → Read → Glob                                 │
│     ↓                                                  │
│  ③ 编写 Plan 文件                                        │
│     Write ~/.claude/plans/happy-cat.md                 │
│     ↓                                                  │
│  ④ 退出并审批                                            │
│     ExitPlanMode → 用户审查 → 批准/拒绝                  │
│     ↓                                                  │
│  ⑤ 开始实现                                              │
│     Edit/Write/Bash 实现代码                            │
└─────────────────────────────────────────────────────────┘

EnterPlanMode 工具

提示词

// src/tools/EnterPlanModeTool/prompt.ts(简化)
export function getEnterPlanModeToolPrompt(): string {
  return `Use this tool proactively when you're about to start a non-trivial implementation task.

## When to Use This Tool

1. **New Feature Implementation**: Adding meaningful new functionality
2. **Multiple Valid Approaches**: The task can be solved in several different ways
3. **Code Modifications**: Changes that affect existing behavior or structure
4. **Multi-File Changes**: The task will likely touch more than 2-3 files
5. **Unclear Requirements**: You need to explore before understanding the full scope

## When NOT to Use This Tool

- Single-line or few-line fixes
- Tasks where the user has given very specific, detailed instructions
- Pure research/exploration tasks (use the Agent tool with explore agent instead)

## What Happens in Plan Mode

In plan mode, you'll:
1. Thoroughly explore the codebase using Glob, Grep, and Read tools
2. Understand existing patterns and architecture
3. Design an implementation approach
4. Present your plan to the user for approval
5. Use AskUserQuestion if you need to clarify approaches
6. Exit plan mode with ExitPlanMode when ready to implement`
}

执行逻辑

// src/tools/EnterPlanModeTool/EnterPlanModeTool.ts(简化)
async call(_input, context) {
  if (context.agentId) {
    throw new Error('EnterPlanMode tool cannot be used in agent contexts')
  }

  const appState = context.getAppState()
  
  // 💡 更新 permission mode 为 'plan'
  context.setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' },
    ),
  }))

  return {
    data: {
      message: 'Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.',
    },
  }
}

模型看到的反馈

mapToolResultToToolResultBlockParam({ message }, toolUseID) {
  return {
    type: 'tool_result',
    content: `${message}

In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval

Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.`,
    tool_use_id: toolUseID,
  }
}

这段文本如何引导模型生成 plan?

这段 tool_result 是模型在 plan 模式下的"行动指南"——它会被添加到对话历史中,模型在下一轮推理时会看到这些指令。

关键机制

  1. 提示词注入tool_result 中的文本会成为模型的"临时指令"
  2. 行为约束:提示词明确告诉模型"DO NOT write or edit any files yet"(除了 plan 文件)
  3. 流程引导:提示词列出了 6 个步骤,模型会按照这个流程执行

模型如何知道要写 plan 文件?

提示词中的第 5 步"Design a concrete implementation strategy"暗示模型需要输出一个具体的实现方案。结合第 6 步"use ExitPlanMode to present your plan",模型会推理出:

模型推理:
"我需要设计一个实现策略 → 
 我需要用 ExitPlanMode 展示 plan → 
 ExitPlanMode 会读取 plan 文件 → 
 所以我需要先写 plan 文件"

ExitPlanMode 工具

为什么需要 ExitPlanMode?

EnterPlanMode 把模型切换到只读模式,但模型如何退出?为什么不能自动退出?

问题 1:模型无法自行退出 Plan 模式

Plan 模式是通过修改 AppState.toolPermissionContext.mode 实现的,这是全局状态。模型没有直接修改状态的能力——它只能通过调用工具来间接影响状态。

模型的困境:
"我已经完成 plan 了,现在想开始实现...
 但我还在 plan 模式,Write/Edit 工具都被禁用...
 我需要一个工具来退出 plan 模式"

问题 2:需要触发用户审批流程

Plan 模式的核心价值是"事前对齐"——用户需要在实现前审查 plan。如果模型自动退出 plan 模式,用户就失去了审查的机会。

错误的流程(自动退出):
模型: [写完 plan 文件]
     [自动退出 plan 模式]
     [开始实现]
用户: 😱 "等等,我还没看 plan 呢!"

正确的流程(ExitPlanMode):
模型: [写完 plan 文件]
     [调用 ExitPlanMode]
     [等待用户审批]
用户: [审查 plan,批准或拒绝]
模型: [收到批准后才开始实现]

问题 3:需要恢复到正确的模式

进入 plan 模式前,模型可能处于不同的状态(defaultautoacceptEdits 等)。退出时需要恢复到进入前的状态,而不是硬编码为 default

// 进入 plan 模式时保存当前状态
prePlanMode: prev.toolPermissionContext.mode  // 可能是 'default' 或 'auto'

// 退出时恢复
mode: prev.toolPermissionContext.prePlanMode ?? 'default'

ExitPlanMode 的三个职责

  1. 读取 plan 文件:从磁盘读取模型写的 plan 内容
  2. 恢复模式状态:把 mode'plan' 恢复到 prePlanMode
  3. 触发审批流程:把 plan 内容展示给用户,等待批准

为什么不能用普通的 Write 工具退出?

因为 Write 工具只负责写文件,不负责修改 AppState。即使模型写完 plan 文件,它仍然处于 plan 模式,无法调用 EditWrite 来实现代码。

为什么不能让用户手动切换模式?

可以(用户可以输入 /mode default),但这会破坏工作流的连贯性:

糟糕的体验:
模型: "我已经完成 plan,请审查后输入 /mode default 继续"
用户: [审查 plan]
     [手动输入 /mode default]
     [手动输入 "开始实现"]

流畅的体验:
模型: [调用 ExitPlanMode]
用户: [看到 plan,点击"批准"按钮]
模型: [自动恢复模式,开始实现]

ExitPlanMode 把"退出 plan 模式 + 用户审批 + 恢复状态"三个步骤合并为一个工具调用,提供了流畅的用户体验。

提示词

// src/tools/ExitPlanModeTool/prompt.ts(简化)
export const EXIT_PLAN_MODE_V2_TOOL_PROMPT = `Use this tool when you have finished planning and are ready for user review.

## How This Tool Works
- You should have already written your plan to the plan file
- This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote
- This tool signals that you're done planning and ready for user review

## Before Using This Tool
Ensure your plan is complete and unambiguous:
- If you have unresolved questions, use AskUserQuestion first
- Once your plan is finalized, use THIS tool to request approval

**Important:** Do NOT use AskUserQuestion to ask "Is this plan okay?" - that's what THIS tool does.`

执行逻辑

// src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts(简化)
async call(input, context) {
  const isAgent = !!context.agentId
  const filePath = getPlanFilePath(context.agentId)
  
  // 💡 读取 plan 文件内容
  const plan = getPlan(context.agentId)

  // 💡 恢复到进入 plan 模式前的状态
  context.setAppState(prev => {
    if (prev.toolPermissionContext.mode !== 'plan') return prev
    
    let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default'
    
    return {
      ...prev,
      toolPermissionContext: {
        ...prev.toolPermissionContext,
        mode: restoreMode,  // 💡 恢复模式
        prePlanMode: undefined,  // 清空记录
      },
    }
  })

  return {
    data: {
      plan,
      isAgent,
      filePath,
    },
  }
}

模型看到的反馈

mapToolResultToToolResultBlockParam({ plan, filePath }, toolUseID) {
  return {
    type: 'tool_result',
    content: `User has approved your plan. You can now start coding.

Your plan has been saved to: ${filePath}
You can refer back to it if needed during implementation.

## Approved Plan:
${plan}`,
    tool_use_id: toolUseID,
  }
}

模型收到这个反馈后,知道 plan 已被批准,可以开始实现。Plan 内容被回显到 tool_result 里,模型可以直接引用(不需要再 Read plan 文件)。


Plan 文件的持久化

Plan 文件路径

// src/utils/plans.ts
export function getPlanFilePath(agentId?: AgentId): string {
  const planSlug = getPlanSlug(getSessionId())

  // 💡 主会话:{slug}.md
  if (!agentId) {
    return join(getPlansDirectory(), `${planSlug}.md`)
  }

  // 💡 子 Agent:{slug}-agent-{agentId}.md
  return join(getPlansDirectory(), `${planSlug}-agent-${agentId}.md`)
}

Plan 文件存储在 ~/.claude/plans/ 目录下(或 settings.json 中配置的 plansDirectory)。

文件命名规则

  • 主会话:{word-slug}.md(如 happy-cat.md
  • 子 Agent:{word-slug}-agent-{agentId}.md(如 happy-cat-agent-researcher.md

word-slug 是随机生成的两个单词组合(如 happy-catbrave-dog),用于区分不同会话的 plan 文件。

Plan 文件的读写

模型在 plan 模式下用 Write 工具写 plan 文件:

// 模型调用
{
  "type": "tool_use",
  "name": "Write",
  "input": {
    "file_path": "/Users/user/.claude/plans/happy-cat.md",
    "content": "# Implementation Plan\n\n## Step 1: ...\n## Step 2: ..."
  }
}

ExitPlanMode 调用时,系统用 getPlan() 读取文件内容:

// src/utils/plans.ts
export function getPlan(agentId?: AgentId): string | null {
  const filePath = getPlanFilePath(agentId)
  try {
    return getFsImplementation().readFileSync(filePath, { encoding: 'utf-8' })
  } catch (error) {
    if (isENOENT(error)) return null
    logError(error)
    return null
  }
}

Plan 文件的恢复机制

在远程会话(CCR web UI)中,文件不会持久化到本地磁盘。为了在会话恢复时找回 plan 内容,系统有三层恢复机制:

1. File Snapshot(增量快照)

每次 plan 文件变化时,系统会把内容写入 transcript 的 file_snapshot 消息:

// src/utils/plans.ts
export async function persistFileSnapshotIfRemote(): Promise<void> {
  if (getEnvironmentKind() === null) return  // 💡 只在远程会话触发

  const plan = getPlan()
  if (plan) {
    const message: SystemFileSnapshotMessage = {
      type: 'system',
      subtype: 'file_snapshot',
      snapshotFiles: [{
        key: 'plan',
        path: getPlanFilePath(),
        content: plan,
      }],
      // ...
    }
    await recordTranscript([message])
  }
}

恢复时,从最后一条 file_snapshot 消息中提取 plan 内容。

2. ExitPlanMode tool_use input

normalizeToolInput 会把 plan 内容注入到 ExitPlanMode 的 tool_use input 里:

// ExitPlanMode tool_use block(简化)
{
  "type": "tool_use",
  "name": "ExitPlanMode",
  "input": {
    "plan": "# Implementation Plan\n\n..."  // 💡 注入的 plan 内容
  }
}

恢复时,从 transcript 中找到 ExitPlanMode tool_use,提取 input.plan

3. User message planContent 字段

在"clear context and implement"流程中,plan 内容会附加到 user message 的 planContent 字段:

{
  "type": "user",
  "content": "Start implementation",
  "planContent": "# Implementation Plan\n\n..."  // 💡 附加的 plan
}

恢复时,从 user message 中提取 planContent

恢复优先级:File Snapshot > ExitPlanMode input > User message planContent


触发机制:模型如何判断是否进入 Plan 模式

Plan 模式有两种触发方式(详见生命周期章节):

  1. 模型主动判断:根据提示词中的条件,模型调用 EnterPlanMode 工具
  2. 用户强制切换:用户输入 /mode plan 命令

本章节重点解析:模型如何通过提示词判断是否进入 Plan 模式

提示词的两个版本

提示词的两个版本

EnterPlanMode 的提示词包含详细的"何时使用"指南,直接影响模型的决策:

外部用户版本(更激进,鼓励使用):

**Prefer using EnterPlanMode** for implementation tasks unless they're simple.

Use it when ANY of these conditions apply:
1. New Feature Implementation - adding meaningful new functionality
2. Multiple Valid Approaches - the task can be solved in several ways
3. Code Modifications - changes that affect existing behavior
4. Architectural Decisions - requires choosing between patterns
5. Multi-File Changes - will likely touch more than 2-3 files
6. Unclear Requirements - need to explore before understanding scope
7. User Preferences Matter - implementation could go multiple ways

中文解读

优先使用 EnterPlanMode 来处理实现任务,除非任务足够简单。

当满足以下任意一个条件时使用:

  1. 新功能实现 — 添加有意义的新功能
  2. 多种有效方案 — 任务可以用多种方式解决
  3. 代码修改 — 影响现有行为的变更
  4. 架构决策 — 需要在多种模式之间选择
  5. 多文件变更 — 可能涉及 2-3 个以上的文件
  6. 需求不明确 — 需要先探索才能理解完整范围
  7. 用户偏好重要 — 实现方式可能有多种合理选择

内部用户版本(更保守,避免过度使用):

Plan mode is valuable when the implementation approach is genuinely unclear.

Use it when:
1. Significant Architectural Ambiguity - multiple reasonable approaches exist
2. Unclear Requirements - need to explore and clarify before progress
3. High-Impact Restructuring - significantly restructure existing code

When in doubt, prefer starting work and using AskUserQuestion for specific questions.

中文解读

Plan 模式在实现方案真正不明确时才有价值。

使用场景:

  1. 重大架构模糊性 — 存在多种合理方案
  2. 需求不明确 — 需要先探索和澄清才能推进
  3. 高影响重构 — 大幅重构现有代码

有疑问时,优先开始工作,用 AskUserQuestion 询问具体问题。

两个版本的差异

维度外部用户版本内部用户版本
默认倾向Prefer using EnterPlanModeWhen in doubt, prefer starting work
触发条件7 个条件,ANY 满足即可3 个条件,需要"genuine ambiguity"
示例任务"Add a delete button" → 用 plan 模式"Add a delete button" → 直接做
设计哲学宁可过度规划,避免返工宁可边做边问,避免过度规划

为什么有两个版本?

  • 外部用户:可能不熟悉代码库,更需要"先看后动"的保护
  • 内部用户(Anthropic 员工):熟悉代码库,过度规划会降低效率

为什么模型会主动触发 Plan 模式?

这是 Claude Code 最精妙的设计之一:通过提示词塑造模型的"风险感知"

传统 Agent 的问题:模型倾向于"立即行动"

模型的默认倾向:
"用户让我做 X → 我知道怎么做 X → 立即调用工具做 X"

问题:
- 模型不会主动评估任务复杂度
- 模型不会主动考虑"是否需要先规划"
- 模型的"自信"往往来自对单个步骤的理解,而非对全局的把握

Claude Code 的解决方案:在提示词中注入"复杂度感知"

提示词通过三种机制改变模型的决策倾向:

机制 1:显式的"Prefer"指令

外部用户版本:
"**Prefer using EnterPlanMode** for implementation tasks unless they're simple."

这句话的作用:
- 把默认倾向从"立即行动"改为"先规划"
- 模型需要主动证明任务"足够简单"才能跳过规划
- 举证责任倒置:不是"为什么要规划",而是"为什么不规划"

这句话在哪里?如何生效?

这句话在 EnterPlanMode 工具的 description 字段中,会被注入到模型的 System Prompt:

// src/tools/EnterPlanModeTool/EnterPlanModeTool.ts
export const EnterPlanModeTool = buildTool({
  name: 'EnterPlanMode',
  
  // 💡 这个函数返回的文本会被加入 System Prompt
  async prompt() {
    return getEnterPlanModeToolPrompt()  // 包含 "Prefer using EnterPlanMode" 的完整提示词
  },
  
  // ...
})

提示词注入流程

① 工具注册阶段
   EnterPlanMode.prompt() 返回完整提示词
   ↓
② System Prompt 构建阶段
   所有工具的 prompt 被合并到 System Prompt
   ↓
③ API 请求阶段
   System Prompt 发送给 Claude API
   ↓
④ 模型推理阶段
   模型看到 "Prefer using EnterPlanMode" 指令
   在判断任务时会优先考虑使用 plan 模式

在 System Prompt 中的位置

System Prompt 结构(简化):
┌─────────────────────────────────────┐
│ You are Claude Code...              │
│                                     │
│ ## Available Tools                  │
│                                     │
│ ### EnterPlanMode                   │
│ **Prefer using EnterPlanMode** for │  ← 💡 "Prefer" 指令在这里
│ implementation tasks unless...      │
│                                     │
│ Use it when ANY of these...         │
│ 1. New Feature Implementation       │
│ 2. Multiple Valid Approaches        │
│ ...                                 │
│                                     │
│ ### Read                            │
│ Reads a file from...                │
│                                     │
│ ### Edit                            │
│ Performs exact string...            │
└─────────────────────────────────────┘

模型在每次推理时都能看到这个 "Prefer" 指令,它会影响模型对所有任务的判断。

为什么这个设计有效?

7 个触发条件(ANY 满足即可):
1. New Feature Implementation
2. Multiple Valid Approaches
3. Code Modifications
4. Architectural Decisions
5. Multi-File Changes
6. Unclear Requirements
7. User Preferences Matter

设计意图:
- 条件多 → 覆盖面广 → 更容易触发
- ANY 逻辑 → 只要满足一个就触发 → 降低触发门槛
- 每个条件都有具体示例 → 模型能快速匹配

机制 3:正反示例的"锚定效应"

GOOD 示例:
"Add a delete button to the user profile"
→ Seems simple but involves: placement, confirmation dialog, API call, error handling

这个示例的作用:
- 告诉模型"看起来简单的任务可能很复杂"
- 锚定模型的复杂度判断标准
- 防止模型低估任务难度

模型的内在推理过程

用户: "给博客系统添加评论功能"

模型的思考链:
① 这是 New Feature ✅(匹配条件 1)
② 我需要决定评论存储方式、审核机制... ✅(匹配条件 2: Multiple Approaches)
③ 会涉及前端组件、后端 API、数据库... ✅(匹配条件 5: Multi-File Changes)
④ 提示词说 "Prefer using EnterPlanMode" ✅
⑤ 提示词的 GOOD 示例里,"Add a delete button" 这种看起来简单的任务都要规划 ✅
⑥ 我的任务比"添加按钮"复杂得多 ✅

结论: 我应该调用 EnterPlanMode,先探索代码库,理解现有架构,再制定方案

为什么这个设计有效?

  1. 利用模型的"遵循指令"倾向

    • 模型天然倾向于遵循提示词中的显式指令
    • "Prefer using EnterPlanMode" 是一个强信号
  2. 利用模型的"模式匹配"能力

    • 模型会把新任务和提示词中的示例对比
    • 示例越具体,模型的判断越准确
  3. 利用模型的"风险规避"倾向

    • 提示词强调"prevents wasted effort"(避免浪费)
    • 模型会倾向于选择"更安全"的路径(先规划)

外部 vs 内部版本的差异本质

维度外部用户版本内部用户版本
默认倾向风险规避(先规划)效率优先(先做)
触发门槛低(7 个条件,ANY)高(3 个条件,genuine ambiguity)
示例锚定"Add a delete button" → 规划"Add a delete button" → 直接做
设计哲学宁可过度规划,避免返工宁可边做边问,避免过度规划

为什么外部用户更激进?

外部用户面临的风险:

  • 不熟悉代码库 → 容易误判任务复杂度
  • 没有领域知识 → 容易选错实现方案
  • 返工成本高 → 改错代码比重新规划更痛苦

内部用户的优势:

  • 熟悉代码库 → 能快速判断任务复杂度
  • 有领域知识 → 能快速选择正确方案
  • 返工成本低 → 改错代码很快

核心洞察:Plan 模式的触发不是"硬编码的规则",而是"提示词塑造的倾向"。模型不是在执行 if-else 逻辑,而是在进行"风险评估 + 成本收益分析"。

模型的决策过程

模型看到用户请求后,会进行这样的推理:

用户: "Add user authentication to the app"

模型推理:
① 这是 New Feature Implementation ✅
② 有 Multiple Valid Approaches(JWT vs Session vs OAuth)✅
③ 涉及 Architectural Decisions ✅
④ 会 touch 多个文件(auth middleware, routes, models)✅
⑤ 提示词说 "Prefer using EnterPlanMode"决策: 调用 EnterPlanMode
用户: "Fix the typo in README"

模型推理:
① 不是 New Feature,是简单修复
② 没有 Multiple Approaches,只有一种做法
③ 不涉及 Architectural Decisions
④ 只改 1 个文件
⑤ 提示词说 "Don't use EnterPlanMode for typos"

决策: 直接用 Edit 工具修复

提示词的"正反示例"策略

提示词包含大量正反示例,帮助模型校准判断:

GOOD 示例(应该用 plan 模式):

User: "Add user authentication to the app"
→ Requires architectural decisions (session vs JWT, middleware structure)

User: "Add a delete button to the user profile"
→ Seems simple but involves: placement, confirmation dialog, API call, error handling

BAD 示例(不应该用 plan 模式):

User: "Fix the typo in the README"
→ Straightforward, no planning needed

User: "Add a console.log to debug this function"
→ Simple, obvious implementation

这些示例直接塑造模型的"任务复杂度感知"——模型会把新任务和这些示例对比,判断是否需要 plan 模式。

真实触发场景分析

让我们看看实际使用中,哪些场景会触发 Plan 模式:

场景决策矩阵

场景触发关键特征决策核心原因
添加评论功能New Feature + 架构选择 + 多文件需确定存储方式、审核机制、通知系统
性能优化(瓶颈未知)Unclear Requirements + 需探索需先 profile 找瓶颈再制定方案
Redux → Zustand 迁移High-Impact Restructuring + 多文件需梳理现有使用情况,设计迁移步骤
Session 丢失 BugUnclear Requirements + 多种可能需先定位根因(前端/后端/Redis/Cookie)
修改按钮颜色单文件 + 明确指令任务简单,无需规划
指定行号修改具体路径 + 具体改动用户已给出详细指令
理解认证系统Pure research用 Explore Agent,不是实现任务
添加删除按钮边界情况外部✅ / 内部❌两个版本提示词的差异体现

经典案例深度分析

案例 1:新功能开发(会触发)

用户: "给这个博客系统添加评论功能"

模型分析:
✅ New Feature Implementation
✅ Multiple Valid Approaches(评论存储方式、审核机制、通知系统)
✅ Multi-File Changes(前端组件、后端 API、数据库 schema)
✅ Architectural Decisions(实时评论 vs 异步加载)

决策: 调用 EnterPlanMode
理由: 需要先确定评论系统的架构(嵌套评论?Markdown 支持?审核流程?)

模型的探索路径:
① Grep 搜索现有的用户交互功能(点赞、分享)
② Read 相关组件,理解数据流和状态管理模式
③ Read 数据库 schema,确认表结构设计规范
④ 写 plan 文件:评论表设计、API 端点、前端组件、审核流程
⑤ ExitPlanMode 提交给用户审批

案例 2:边界情况(外部 vs 内部差异)

用户: "给用户资料页添加一个删除账户按钮"

外部用户版本(激进):
✅ 看起来简单,但涉及:按钮位置、确认对话框、API 调用、错误处理、状态更新
✅ 提示词示例明确说 "Add a delete button to the user profile" 应该用 plan 模式

决策: 调用 EnterPlanMode

内部用户版本(保守):
❌ 实现路径清晰,按照现有模式添加即可
❌ 提示词示例说 "Add a delete button to the user profile" 直接做

决策: 直接实现

这个差异体现了两个版本的核心哲学:
- 外部版本: 宁可过度规划,避免返工(用户不熟悉代码库)
- 内部版本: 宁可边做边问,避免过度规划(开发者熟悉代码库)

触发规律总结

任务特征是否触发 Plan 模式原因
需要探索代码库才能理解需求Unclear Requirements
有多种合理的实现方案Multiple Valid Approaches
涉及 3+ 个文件的修改Multi-File Changes
需要做架构决策Architectural Decisions
用户给出了具体的文件路径和改动指令明确,无需规划
只是查看/理解代码用 Explore Agent
简单的样式/文案修改任务简单,直接做

工具权限:isReadOnly 的约束机制

Plan 模式的核心约束:只有 isReadOnly: true 的工具才能执行

工具的 isReadOnly 声明

每个工具在定义时声明自己是否只读:

// src/tools/FileReadTool/FileReadTool.ts
export const FileReadTool = buildTool({
  name: 'Read',
  isReadOnly() { return true },  // ✅ 只读,plan 模式可用
  // ...
})

// src/tools/FileEditTool/FileEditTool.ts
export const FileEditTool = buildTool({
  name: 'Edit',
  isReadOnly() { return false },  // ❌ 写操作,plan 模式禁用
  // ...
})

常见工具的 isReadOnly 状态:

工具isReadOnlyPlan 模式
Read✅ true可用
Grep✅ true可用
Glob✅ true可用
Edit❌ false禁用
Write❌ false禁用
Bash❌ false禁用
EnterPlanMode✅ true可用
ExitPlanMode❌ false可用(特殊豁免)

权限检查逻辑

工具执行前,checkPermissions 会检查当前模式:

// src/utils/permissions/permissions.ts(简化)
export async function checkPermissions(
  tool: Tool,
  context: ToolUseContext,
): Promise<PermissionResult> {
  const mode = context.getAppState().toolPermissionContext.mode

  // 💡 Plan 模式下,非只读工具被拒绝
  if (mode === 'plan' && !tool.isReadOnly()) {
    return {
      behavior: 'deny',
      message: `${tool.name} is not allowed in plan mode. Plan mode is read-only.`,
    }
  }

  // 其他权限检查...
}

模型收到拒绝消息后,会意识到当前在 plan 模式,停止尝试写操作。

ExitPlanMode 的特殊豁免

ExitPlanMode 声明 isReadOnly: false(因为它会写 plan 文件到磁盘),但在 plan 模式下仍然可用——这是如何实现的?

豁免机制:通过 validateInput 检查当前模式

// src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts
export const ExitPlanModeV2Tool = buildTool({
  name: 'ExitPlanMode',
  isReadOnly() { return false },  // 💡 声明为非只读(因为会写文件)
  
  async validateInput(_input, { getAppState }) {
    const mode = getAppState().toolPermissionContext.mode
    
    // 💡 只在 plan 模式下允许调用
    if (mode !== 'plan') {
      return {
        result: false,
        message: 'You are not in plan mode. This tool is only for exiting plan mode.',
        errorCode: 1,
      }
    }
    
    return { result: true }  // 💡 plan 模式下通过验证
  },
  
  // ...
})

为什么不受 isReadOnly 检查限制?

因为 validateInput 在权限检查之前执行:

工具调用流程:
① validateInput 检查 → ExitPlanMode 在 plan 模式下返回 true
② checkPermissions 检查 → 返回 'ask'(需要用户确认)
③ 用户批准
④ call() 执行 → 写 plan 文件、恢复模式

关键: validateInput 的检查优先于 isReadOnly 的通用检查

ExitPlanMode 是唯一一个"只能在 plan 模式调用"的工具——它的 validateInput 强制要求 mode === 'plan',这保证了它只在需要的时候可用。


模型切换:200k token 阈值

Plan 模式下,如果模型的最近一次输出超过 200k tokens,系统会自动切换到更大上下文的模型。

为什么需要切换?

Plan 模式的典型场景:

① 模型用 Grep 找出 50 个相关文件
② 模型用 Read 读取这 50 个文件(每个文件 2k tokens)
③ 模型分析架构,写 plan 文件

问题:第 ② 步会产生 100k tokens 的输入
     如果模型上下文只有 200k,剩余空间不足以完成分析

解决方案:检测到输出超过 200k 时,切换到 1M 上下文的模型(如 claude-opus-4-6-1m)。

检测逻辑

// src/utils/tokens.ts
export function doesMostRecentAssistantMessageExceed200k(
  messages: Message[],
): boolean {
  // 找到最后一条 assistant 消息
  const lastAssistant = messages.findLast(m => m.role === 'assistant')
  if (!lastAssistant) return false

  // 计算这条消息的 token 数(包含所有 tool_use blocks)
  const tokens = tokenCountWithEstimation(lastAssistant.content)
  return tokens > 200_000
}

模型切换逻辑

// src/query.ts(简化)
const permissionMode = appState.toolPermissionContext.mode
let currentModel = getRuntimeMainLoopModel({
  permissionMode,
  mainLoopModel: toolUseContext.options.mainLoopModel,
  exceeds200kTokens:
    permissionMode === 'plan' &&
    doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})
// src/utils/model/model.ts
export function getRuntimeMainLoopModel(params: {
  permissionMode: PermissionMode
  mainLoopModel: string
  exceeds200kTokens?: boolean
}): ModelName {
  const { permissionMode, mainLoopModel, exceeds200kTokens = false } = params

  // 💡 opusplan 模式:plan 模式下用 Opus,超过 200k 时切换到 1M 版本
  if (
    getUserSpecifiedModelSetting() === 'opusplan' &&
    permissionMode === 'plan' &&
    !exceeds200kTokens
  ) {
    return getDefaultOpusModel()  // claude-opus-4-6
  }

  // 💡 haiku 用户在 plan 模式下自动升级到 Sonnet
  if (getUserSpecifiedModelSetting() === 'haiku' && permissionMode === 'plan') {
    return getDefaultSonnetModel()
  }

  return mainLoopModel
}

设计哲学:Plan 模式是"思考密集型"任务,需要更大的上下文窗口来容纳探索过程中的大量文件内容。自动切换模型避免了"读到一半发现上下文不够"的尴尬。

完整的模型切换矩阵

用户设置当前模式输出 Token 数实际使用模型说明
opusplanplan< 200kclaude-opus-4-6Plan 模式专用配置
opusplanplan≥ 200kclaude-opus-4-6-1m💡 自动升级到 1M 上下文
opusplandefault/auto任意用户指定的模型非 plan 模式不触发特殊逻辑
haikuplan任意claude-sonnet-4-6💡 Haiku 在 plan 模式下强制升级
haikudefault/auto任意claude-haiku-4-5非 plan 模式保持 Haiku
sonnetplan任意claude-sonnet-4-6保持不变
default / 其他plan任意用户指定的模型保持不变

关键规则

  • 只有 opusplanhaiku 在 plan 模式下有特殊行为
  • opusplan 的 200k 阈值检查只在 plan 模式生效
  • 其他模型设置在 plan 模式下保持不变

EnterPlanMode 工具

提示词

// src/tools/EnterPlanModeTool/prompt.ts(简化)
export function getEnterPlanModeToolPrompt(): string {
  return `Use this tool proactively when you're about to start a non-trivial implementation task.

## When to Use This Tool

1. **New Feature Implementation**: Adding meaningful new functionality
2. **Multiple Valid Approaches**: The task can be solved in several different ways
3. **Code Modifications**: Changes that affect existing behavior or structure
4. **Multi-File Changes**: The task will likely touch more than 2-3 files
5. **Unclear Requirements**: You need to explore before understanding the full scope

## When NOT to Use This Tool

- Single-line or few-line fixes
- Tasks where the user has given very specific, detailed instructions
- Pure research/exploration tasks (use the Agent tool with explore agent instead)

## What Happens in Plan Mode

In plan mode, you'll:
1. Thoroughly explore the codebase using Glob, Grep, and Read tools
2. Understand existing patterns and architecture
3. Design an implementation approach
4. Present your plan to the user for approval
5. Use AskUserQuestion if you need to clarify approaches
6. Exit plan mode with ExitPlanMode when ready to implement`
}

执行逻辑

// src/tools/EnterPlanModeTool/EnterPlanModeTool.ts(简化)
async call(_input, context) {
  if (context.agentId) {
    throw new Error('EnterPlanMode tool cannot be used in agent contexts')
  }

  const appState = context.getAppState()
  
  // 💡 更新 permission mode 为 'plan'
  context.setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' },
    ),
  }))

  return {
    data: {
      message: 'Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.',
    },
  }
}

模型看到的反馈

mapToolResultToToolResultBlockParam({ message }, toolUseID) {
  return {
    type: 'tool_result',
    content: `${message}

In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval

Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.`,
    tool_use_id: toolUseID,
  }
}

这段文本如何引导模型生成 plan?

这段 tool_result 是模型在 plan 模式下的"行动指南"——它会被添加到对话历史中,模型在下一轮推理时会看到这些指令。

完整的 Plan 生成流程

① 模型调用 EnterPlanMode 工具
   
② 工具执行,返回 tool_result(包含上面的行动指南)
   
③ tool_result 被添加到对话历史
   
④ 模型看到 tool_result,理解当前处于 plan 模式
   
⑤ 模型按照指南开始探索:
   - 调用 Grep 搜索相关文件
   - 调用 Read 理解现有架构
   - 调用 Glob 确认依赖关系
   
⑥ 模型基于探索结果,调用 Write 工具写 plan 文件:
   {
     "name": "Write",
     "input": {
       "file_path": "/Users/user/.claude/plans/happy-cat.md",
       "content": "# Implementation Plan\n\n## Step 1: ...\n## Step 2: ..."
     }
   }
   
⑦ 模型调用 ExitPlanMode 提交 plan 给用户审批

关键机制

  1. 提示词注入tool_result 中的文本会成为模型的"临时指令"
  2. 行为约束:提示词明确告诉模型"DO NOT write or edit any files yet"(除了 plan 文件)
  3. 流程引导:提示词列出了 6 个步骤,模型会按照这个流程执行

模型如何知道要写 plan 文件?

提示词中的第 5 步"Design a concrete implementation strategy"暗示模型需要输出一个具体的实现方案。结合第 6 步"use ExitPlanMode to present your plan",模型会推理出:

模型推理:
"我需要设计一个实现策略 → 
 我需要用 ExitPlanMode 展示 plan → 
 ExitPlanMode 会读取 plan 文件 → 
 所以我需要先写 plan 文件"

Plan 文件的内容结构

模型会根据任务类型生成不同结构的 plan,但通常包含:

# Implementation Plan

## Overview
[任务的整体描述和目标]

## Current State Analysis
[通过 Grep/Read 探索得到的现有架构分析]

## Proposed Approach
[选择的实现方案和理由]

## Implementation Steps
1. [步骤 1:具体的文件和改动]
2. [步骤 2:...]
3. [步骤 3:...]

## Risks and Considerations
[潜在风险和需要注意的点]

## Testing Strategy
[如何验证实现的正确性]

这个结构不是硬编码的,而是模型根据提示词中的"Design a concrete implementation strategy"自行推理出来的。


ExitPlanMode 工具

为什么需要 ExitPlanMode?

EnterPlanMode 把模型切换到只读模式,但模型如何退出?为什么不能自动退出?

问题 1:模型无法自行退出 Plan 模式

Plan 模式是通过修改 AppState.toolPermissionContext.mode 实现的,这是全局状态。模型没有直接修改状态的能力——它只能通过调用工具来间接影响状态。

模型的困境:
"我已经完成 plan 了,现在想开始实现...
 但我还在 plan 模式,Write/Edit 工具都被禁用...
 我需要一个工具来退出 plan 模式"

问题 2:需要触发用户审批流程

Plan 模式的核心价值是"事前对齐"——用户需要在实现前审查 plan。如果模型自动退出 plan 模式,用户就失去了审查的机会。

错误的流程(自动退出):
模型: [写完 plan 文件]
     [自动退出 plan 模式]
     [开始实现]
用户: 😱 "等等,我还没看 plan 呢!"

正确的流程(ExitPlanMode):
模型: [写完 plan 文件]
     [调用 ExitPlanMode]
     [等待用户审批]
用户: [审查 plan,批准或拒绝]
模型: [收到批准后才开始实现]

问题 3:需要恢复到正确的模式

进入 plan 模式前,模型可能处于不同的状态(defaultautoacceptEdits 等)。退出时需要恢复到进入前的状态,而不是硬编码为 default

// 进入 plan 模式时保存当前状态
prePlanMode: prev.toolPermissionContext.mode  // 可能是 'default' 或 'auto'

// 退出时恢复
mode: prev.toolPermissionContext.prePlanMode ?? 'default'

ExitPlanMode 的三个职责

  1. 读取 plan 文件:从磁盘读取模型写的 plan 内容
  2. 恢复模式状态:把 mode'plan' 恢复到 prePlanMode
  3. 触发审批流程:把 plan 内容展示给用户,等待批准

为什么不能用普通的 Write 工具退出?

因为 Write 工具只负责写文件,不负责修改 AppState。即使模型写完 plan 文件,它仍然处于 plan 模式,无法调用 EditWrite 来实现代码。

为什么不能让用户手动切换模式?

可以(用户可以输入 /mode default),但这会破坏工作流的连贯性:

糟糕的体验:
模型: "我已经完成 plan,请审查后输入 /mode default 继续"
用户: [审查 plan]
     [手动输入 /mode default]
     [手动输入 "开始实现"]

流畅的体验:
模型: [调用 ExitPlanMode]
用户: [看到 plan,点击"批准"按钮]
模型: [自动恢复模式,开始实现]

ExitPlanMode 把"退出 plan 模式 + 用户审批 + 恢复状态"三个步骤合并为一个工具调用,提供了流畅的用户体验。

提示词

// src/tools/ExitPlanModeTool/prompt.ts(简化)
export const EXIT_PLAN_MODE_V2_TOOL_PROMPT = `Use this tool ONLY when the user explicitly asks to work in a worktree.

## How This Tool Works
- You should have already written your plan to the plan file
- This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote
- This tool signals that you're done planning and ready for user review

## Before Using This Tool
Ensure your plan is complete and unambiguous:
- If you have unresolved questions, use AskUserQuestion first
- Once your plan is finalized, use THIS tool to request approval

**Important:** Do NOT use AskUserQuestion to ask "Is this plan okay?" - that's what THIS tool does.`

执行逻辑

// src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts(简化)
async call(input, context) {
  const isAgent = !!context.agentId
  const filePath = getPlanFilePath(context.agentId)
  
  // 💡 读取 plan 文件内容
  const plan = getPlan(context.agentId)

  // 💡 恢复到进入 plan 模式前的状态
  context.setAppState(prev => {
    if (prev.toolPermissionContext.mode !== 'plan') return prev
    
    let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default'
    
    return {
      ...prev,
      toolPermissionContext: {
        ...prev.toolPermissionContext,
        mode: restoreMode,  // 💡 恢复模式
        prePlanMode: undefined,  // 清空记录
      },
    }
  })

  return {
    data: {
      plan,
      isAgent,
      filePath,
    },
  }
}

模型看到的反馈

mapToolResultToToolResultBlockParam({ plan, filePath }, toolUseID) {
  return {
    type: 'tool_result',
    content: `User has approved your plan. You can now start coding.

Your plan has been saved to: ${filePath}
You can refer back to it if needed during implementation.

## Approved Plan:
${plan}`,
    tool_use_id: toolUseID,
  }
}

模型收到这个反馈后,知道 plan 已被批准,可以开始实现。Plan 内容被回显到 tool_result 里,模型可以直接引用(不需要再 Read plan 文件)。


Plan 文件的持久化

Plan 文件路径

// src/utils/plans.ts
export function getPlanFilePath(agentId?: AgentId): string {
  const planSlug = getPlanSlug(getSessionId())

  // 💡 主会话:{slug}.md
  if (!agentId) {
    return join(getPlansDirectory(), `${planSlug}.md`)
  }

  // 💡 子 Agent:{slug}-agent-{agentId}.md
  return join(getPlansDirectory(), `${planSlug}-agent-${agentId}.md`)
}

Plan 文件存储在 ~/.claude/plans/ 目录下(或 settings.json 中配置的 plansDirectory)。

文件命名规则

  • 主会话:{word-slug}.md(如 happy-cat.md
  • 子 Agent:{word-slug}-agent-{agentId}.md(如 happy-cat-agent-researcher.md

word-slug 是随机生成的两个单词组合(如 happy-catbrave-dog),用于区分不同会话的 plan 文件。

Plan 文件的读写

模型在 plan 模式下用 Write 工具写 plan 文件:

// 模型调用
{
  "type": "tool_use",
  "name": "Write",
  "input": {
    "file_path": "/Users/user/.claude/plans/happy-cat.md",
    "content": "# Implementation Plan\n\n## Step 1: ...\n## Step 2: ..."
  }
}

ExitPlanMode 调用时,系统用 getPlan() 读取文件内容:

// src/utils/plans.ts
export function getPlan(agentId?: AgentId): string | null {
  const filePath = getPlanFilePath(agentId)
  try {
    return getFsImplementation().readFileSync(filePath, { encoding: 'utf-8' })
  } catch (error) {
    if (isENOENT(error)) return null
    logError(error)
    return null
  }
}

Plan 文件的恢复机制

在远程会话(CCR web UI)中,文件不会持久化到本地磁盘。为了在会话恢复时找回 plan 内容,系统有三层恢复机制:

1. File Snapshot(增量快照)

每次 plan 文件变化时,系统会把内容写入 transcript 的 file_snapshot 消息:

// src/utils/plans.ts
export async function persistFileSnapshotIfRemote(): Promise<void> {
  if (getEnvironmentKind() === null) return  // 💡 只在远程会话触发

  const plan = getPlan()
  if (plan) {
    const message: SystemFileSnapshotMessage = {
      type: 'system',
      subtype: 'file_snapshot',
      snapshotFiles: [{
        key: 'plan',
        path: getPlanFilePath(),
        content: plan,
      }],
      // ...
    }
    await recordTranscript([message])
  }
}

恢复时,从最后一条 file_snapshot 消息中提取 plan 内容。

2. ExitPlanMode tool_use input

normalizeToolInput 会把 plan 内容注入到 ExitPlanMode 的 tool_use input 里:

// ExitPlanMode tool_use block(简化)
{
  "type": "tool_use",
  "name": "ExitPlanMode",
  "input": {
    "plan": "# Implementation Plan\n\n..."  // 💡 注入的 plan 内容
  }
}

恢复时,从 transcript 中找到 ExitPlanMode tool_use,提取 input.plan

3. User message planContent 字段

在"clear context and implement"流程中,plan 内容会附加到 user message 的 planContent 字段:

{
  "type": "user",
  "content": "Start implementation",
  "planContent": "# Implementation Plan\n\n..."  // 💡 附加的 plan
}

恢复时,从 user message 中提取 planContent

恢复优先级:File Snapshot > ExitPlanMode input > User message planContent


本系列后续文章

工具类型对应文章
问答工具(AskUserQuestion)005 AskUserQuestion:人机协作的接口
Agent 工具(Agent/Skill)006 AgentTool:子 Agent 的生命周期
MCP 工具007 MCP 工具:外部能力的接入协议
Skill 工具008 SkillTool:可复用工作流的实现
Team 工具(TeamCreate/TeamDelete)009 TeamTool:多 Agent 协作编排

系列导航


常见问题 FAQ

Q:Plan 模式下模型能调用 Bash 工具吗?

不能。BashTool 声明 isReadOnly: false,在 plan 模式下被禁用。即使是只读命令(如 lscat),也不允许——因为 Bash 可以执行任意命令,无法保证只读性。

如果模型需要列出文件,应该用 Glob 工具;如果需要读文件,应该用 Read 工具。


Q:ExitPlanMode 为什么声明 isReadOnly: false 但仍能在 plan 模式执行?

因为 ExitPlanMode 需要写 plan 文件到磁盘(持久化),所以声明 isReadOnly: false。但它是退出 plan 模式的唯一途径,必须在 plan 模式下可用——这是硬编码的豁免。

权限检查逻辑对 ExitPlanMode 有特殊处理,允许它绕过 isReadOnly 检查。


Q:Plan 模式下模型能创建新文件吗?

不能。Write 工具声明 isReadOnly: false,在 plan 模式下被禁用。唯一的例外是写 plan 文件本身(通过 Write 工具写到 ~/.claude/plans/ 目录)。

模型可以在 plan 文件里描述"需要创建哪些新文件",但不能实际创建它们——这是退出 plan 模式后的实现阶段才能做的事。


Q:如果模型在 plan 模式下尝试调用 Edit 工具,会发生什么?

工具调用会被拒绝,模型收到错误消息:

Edit is not allowed in plan mode. Plan mode is read-only.

模型看到这个错误后,会意识到当前在 plan 模式,停止尝试写操作,转而继续探索或写 plan 文件。


Q:Plan 文件的 slug 是如何生成的?为什么不用 sessionId?

Slug 是随机生成的两个单词组合(如 happy-cat),比 UUID 更易读。使用 slug 而不是 sessionId 的原因:

  1. 可读性happy-cat.mda3f2b1c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o.md 更容易识别
  2. 冲突检测:生成 slug 时会检查文件是否已存在,最多重试 10 次
  3. 会话隔离:每个会话有独立的 slug,fork 会话时生成新 slug(避免覆盖原会话的 plan)

Slug 在会话首次需要 plan 文件时懒加载生成,并缓存在 planSlugCache Map 中。


Q:Plan Agent 的 disallowedTools 和 Plan 模式的 isReadOnly 检查有什么区别?

两者都是为了限制写操作,但实现机制不同:

Plan 模式的 isReadOnly 检查(运行时检查):

// 每次工具调用前检查
if (mode === 'plan' && !tool.isReadOnly()) {
  return { behavior: 'deny', message: '...' }
}

Plan Agent 的 disallowedTools(工具列表过滤):

// Plan Agent 定义时声明禁用的工具
disallowedTools: [
  'Agent',
  'ExitPlanMode',
  'Edit',
  'Write',
  'NotebookEdit',
]
// 这些工具的 schema 不会发给 Plan Agent

关键区别

  • Plan 模式:工具 schema 仍然发给模型,但调用时被拒绝(模型会看到错误消息)
  • Plan Agent:工具 schema 根本不发给模型(模型不知道这些工具存在)

为什么 Plan Agent 不用 isReadOnly 检查?

因为 Plan Agent 是独立的子 Agent,有自己的工具列表。通过 disallowedTools 过滤工具列表更简洁——模型不会尝试调用它根本不知道的工具。

而 Plan 模式是主会话的状态切换,工具列表不变,只能通过运行时检查拦截。