[BUG] ExitPlanMode reads the plan file at parse time, not run time: approval/canUseTool shows an empty or previous plan when Write is in the same message (Opus 5.5)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet (closest: #95216, whose second comment describes the same one-message cause for the blank dialog; #82369 closed as stale; #79972. This report adds the stale-revision case, the SDK
canUseToolpath, and the root cause in the binary.) - [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.280 bundled with
claude-agent-sdk0.2.158; the same code is in 2.1.281)
What's Wrong?
The plan that ExitPlanMode presents for approval is read from the plan file when the tool_use block finishes streaming in from the API, not when the tool runs. normalizeToolInput injects plan and planFilePath into the call's input at content_block_stop.
claude-opus-5-5 routinely puts the Write of the plan file and ExitPlanMode (empty input, as the schema asks) in the same assistant message: in the transcripts on this machine it did so for 36 of its 45 ExitPlanMode calls, against 0 of 131 for claude-opus-5, claude-haiku-4-5, claude-sonnet-5, claude-fable-5 and claude-fable-5-1, which write the file in one reply and call ExitPlanMode in the next. The Write has not executed yet when the ExitPlanMode block is normalized, so the injected plan is:
- absent on a first plan (no file yet; the input arrives as
{}), which is the blank dialog of #95216, and - the previous revision after feedback (
Writeof v2 andExitPlanModein one message;planstill holds v1).
Everything downstream of the permission "ask" path shows that copy: the interactive approval dialog, the VS Code webview (inputs.plan), and an SDK canUseTool callback (permission_request["input"]["plan"]). In our SDK-based agent the human reviewer saw an empty plan, or a revision that lacked the feedback they had just given, while the file (and the model) had the right one.
Tool ordering is not the problem: ExitPlanMode.isConcurrencySafe() is true, Write's is false, so the executor runs the Write first and it has completed by the time ExitPlanMode's permission request goes out. Only the snapshot is early. After approval the CLI drops an unchanged plan and the tool re-reads the file, so the model gets the right plan; but a plan that comes back changed from the permission handler is persisted over the file and reported as "Approved Plan (edited by user)", so a reviewer who edits the stale text in the dialog overwrites the newer file.
Other models never hit this because they never batch the two calls; the CLI side is unchanged since at least 2.1.259 (see the regression section).
What Should Happen?
The approval prompt (and the canUseTool input) should carry the plan file as it is when the tool is about to run, i.e. read it at permission-check time (or in the executor right before the permission request), not in the stream parser. Alternatively normalizeToolInput could leave plan out and checkPermissions could read the file.
Error Messages/Logs
# Our canUseTool callback, logging the input it received, CLI 2.1.280, model claude-opus-5-5.
# Run A, first plan: the input has no plan at all (file did not exist when the block streamed in)
can_use_tool(ExitPlanMode, toolu_017mJP7HaQNZ6bfgDcqMGT3t) input keys=[]
# Run B, revision: the input carries the previous plan (907 chars); the file already holds v2 (1556 chars, with the requested --shout flag)
can_use_tool(ExitPlanMode, toolu_01JsSRtcu2KNJbgs9n1pRpYd) input keys=['plan', 'planFilePath']
webhook plan 907 chars, file 1556 chars -> STALE, plan mentions --shout: False
# The transcript, tool_use blocks grouped by API message id (run B; the recorded ExitPlanMode input is the normalized one):
message gx4rnYFq : ['Write -> <slug>.md (907 chars)']
message v9awx1cY : ['ExitPlanMode input={"plan": "# Plan: greet.py ...'] # separate messages: copy correct
message CXdyD7DQ : ['Write -> <slug>.md (1556 chars)', 'ExitPlanMode input={"plan": "# Plan: greet.py ... (the 907-char v1)'] # same message: copy stale
# Run A, first plan:
message fkYTiyUh : ['Write -> <slug>.md (878 chars)', 'ExitPlanMode input={}']
# After approving run B's stale copy unchanged, the tool_result the model received was the fresh plan
# ("User has approved your plan ... ## Approved Plan:" with --shout, not "edited by user"), and the file stayed at 1556 chars:
# the CLI re-reads the file at run time, so only the review was wrong.
Steps to Reproduce
Any consumer of the permission "ask" path shows it; the SDK makes the input observable. This is the shape our agent uses (Python claude-agent-sdk 0.2.158, which bundles CLI 2.1.280):
import asyncio, tempfile
from pathlib import Path
from claude_agent_sdk import (ClaudeAgentOptions, ClaudeSDKClient,
PermissionResultAllow, PermissionResultDeny, ResultMessage)
calls = 0
async def can_use_tool(tool_name, tool_input, context):
global calls
if tool_name != "ExitPlanMode":
return PermissionResultDeny(message="not in this repro")
calls += 1
copied = tool_input.get("plan")
path = tool_input.get("planFilePath")
on_disk = Path(path).read_text() if path else None
print(f"#{calls} keys={sorted(tool_input)} plan in input={len(copied or '')} chars, "
f"plan file now={len(on_disk or '')} chars -> {'SAME' if copied == on_disk else 'STALE'}")
if calls == 1:
return PermissionResultDeny(message="The user rejected the plan: also add a --shout flag "
"that prints the greeting in upper case. Revise the plan and present it again.")
return PermissionResultAllow()
async def main():
options = ClaudeAgentOptions(cwd=tempfile.mkdtemp(), model="claude-opus-5-5",
permission_mode="plan", can_use_tool=can_use_tool,
allowed_tools=["Read", "Write", "Edit", "Glob", "Grep", "Bash"])
async with ClaudeSDKClient(options) as client:
await client.query("Plan a small Python script greet.py in this directory that prints a JSON "
"greeting for Ada. Write the plan and present it with ExitPlanMode. "
"Do not create any file before I approve the plan.")
async for message in client.receive_response():
if isinstance(message, ResultMessage):
print("result:", message.subtype)
asyncio.run(main())
- Run it a few times on
claude-opus-5-5. - Whenever the model batches
Write+ExitPlanModein one message (most runs), callback #1 printskeys=[](no plan at all) or callback #2 printsSTALEwith the file already larger than the copy. In our agent, which does exactly these calls: 2 of 2 runs affected (one of each kind); an earlier batch of 11 runs had 5 affected. Withclaude-haiku-4-5orclaude-sonnet-5: 0 affected, they use two replies. - Same thing in the interactive CLI or the VS Code extension: in plan mode on Opus 5.5, give feedback on a plan, and the next approval dialog shows the previous plan (or an empty one on the first plan).
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version (from the user's side). The same SDK agent reviewed correct plans for weeks with claude-opus-5 on CLI 2.1.259 (claude-agent-sdk 0.2.152) and 2.1.277 (0.2.157); it broke the day we moved to claude-opus-5-5 and 0.2.158 / 2.1.280 together. But the CLI did not change on this point: 2.1.259, 2.1.277, 2.1.280 and 2.1.281 all carry the identical normalizeToolInput branch (h!==null?{...n,plan:h,planFilePath:g}:n, checked in each bundled binary). What changed is the model: Opus 5.5 is the first one that batches the plan-file Write and ExitPlanMode in one message, which exposes the parse-time read. So: a latent CLI bug since at least 2.1.259, surfaced by Opus 5.5.
Last Working Version
2.1.277 with claude-opus-5 (and 2.1.259 before it). Not a CLI version boundary, see above.
Claude Code Version
2.1.280 (Claude Code), bundled with claude-agent-sdk 0.2.158. 2.1.281 (npm latest), 2.1.277 and 2.1.259 contain the same code for this path.
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment
Additional Information
Where it is in the 2.1.280 binary (minified names from that build):
normalizeToolInput(RUe;Wjein 2.1.281):case ExitPlanMode: { let g = getPlanFilePath(agentId); notePlanFileForgotten(g); let h = getPlan(agentId); return h !== null ? {...input, plan: h, planFilePath: g} : input }. It is called from the streaming handler oncontent_block_stopfor each tool_use block, and from the non-streaming fallback, i.e. before any tool of that message has run.- The tool:
isConcurrencySafe() { return true },checkPermissions(r) { if (auto) return {behavior: "allow", updatedInput: strip(ExitPlanMode, r)}; return {behavior: "ask", message: "Exit plan mode?", updatedInput: r} }. The auto path stripsplan/planFilePathvia the injected-field tablenew Map([[ExitPlanMode, new Set(["plan", "planFilePath"])]]); the ask path hands the stale copy to the dialog / webview / SDK. - After the ask path returns, the executor strips injected keys that came back unchanged and
calldoesA = r.plan; v = A ?? await getPlan(); if (A !== undefined) await persistPlan(planFilePath, A)and reportsplanWasEdited: A !== undefined.
Workaround we use in our SDK agent: in canUseTool, read planFilePath from disk (the sibling Write has landed by then), show that to the reviewer, and return updatedInput with plan/planFilePath removed; when neither key is present, deny with "No plan file existed when ExitPlanMode was called. Write your plan to the plan file first, then call ExitPlanMode again." (the model retries at once and the retry carries the file).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗