[BUG] ExitPlanMode race condition in setAppState — decompiled root cause analysis (v2.1.72)
Summary
Plan mode fails to exit after user approval in v2.1.72. The model enters an infinite loop asking "what do you want to change?" despite the user approving the plan. This affects multiple platforms and configurations. I decompiled the binary to locate the exact root cause in ExitPlanModeV2Tool.
Root Cause (from binary analysis)
I extracted the ExitPlanModeV2Tool.call() implementation from the compiled binary (v2.1.72, Linux x86_64). The critical state transition happens in a setAppState callback:
// Decompiled from v2.1.72 binary — ExitPlanModeV2Tool state transition
$.setAppState((K) => {
// Guard: if mode is already not "plan", return unchanged
if (K.toolPermissionContext.mode !== "plan") return K;
QT(true); // flag (likely planCompleted)
kh(true); // flag (likely planApproved)
// Resolve target mode from prePlanMode
let P = K.toolPermissionContext.prePlanMode ?? "default";
let O = P === "ultraplan" ? "default" : P;
// Auto-mode gate check
{
if ((O === "auto" || false) && !(_lH?.isAutoModeGateEnabled() ?? false)) {
O = "default"; // Fallback if auto-mode gate is disabled
// Logs: "[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=... but gate is off"
}
let Y = O === "auto" || false;
if (fk6?.setAutoModeActive(Y), P === "auto" && O !== "auto") {
hv(true); // notification
}
}
// Restore permissions
let w = O !== "auto"
? _lH?.restoreDangerousPermissions(K.toolPermissionContext)
?? K.toolPermissionContext
: K.toolPermissionContext;
return {
...K,
toolPermissionContext: { ...w, mode: O, prePlanMode: void 0 }
};
});
The bug (two potential failure paths):
Path 1 — Race condition in setAppState: Since setAppState is asynchronous (React-style batched updates), the callback receives a snapshot of state at execution time. If any other state update modifies toolPermissionContext.mode between the ExitPlanMode tool call and the setAppState callback execution, the guard mode !== "plan" returns early with no transition. The mode stays in limbo — ExitPlanMode "succeeded" (tool result returned) but the state never actually changed.
Path 2 — restoreDangerousPermissions override: When O !== "auto", the function calls _lH?.restoreDangerousPermissions(K.toolPermissionContext). If this returns a context object that still has mode: "plan" (because it restores from a snapshot taken before the transition), the spread {...w, mode: O} should override it — but if restoreDangerousPermissions returns a deeply nested or frozen object, the override may not take effect as expected.
Path 3 — Enqueue/dequeue spin loop (#26651): The plan content gets enqueued as a synthetic user message after ExitPlanMode. If the message dispatch doesn't recognize that mode has changed, it re-enters plan mode processing, creating the "thinking indicator resets every ~1.5s" pattern reported in #26651.
Suggested Fix
// Option A: Synchronous state transition (eliminates race)
// Instead of setAppState callback, directly mutate and flush:
const currentState = $.getAppState();
if (currentState.toolPermissionContext.mode === "plan") {
const targetMode = resolveTargetMode(currentState.toolPermissionContext.prePlanMode);
$.setAppState({
...currentState,
toolPermissionContext: {
...currentState.toolPermissionContext,
mode: targetMode,
prePlanMode: undefined
}
});
}
// Option B: Remove the early-return guard (defensive)
// The mode transition should be idempotent — setting mode to "default"
// when it's already "default" is harmless, but skipping the transition
// when mode was changed by a concurrent update is catastrophic.
$.setAppState((K) => {
// REMOVED: if (K.toolPermissionContext.mode !== "plan") return K;
// Always perform the transition — worst case it's a no-op on mode value
let P = K.toolPermissionContext.prePlanMode ?? "default";
// ... rest of logic ...
});
Evidence That Hooks Are NOT the Cause
I audited all 22 active hooks against the ExitPlanMode tool name:
| Hook Type | Matcher | Matches ExitPlanMode? |
|-----------|---------|----------------------|
| PreToolUse | ^Read$, ^(Write\|Edit\|...)$, ^Task$, ^TaskUpdate$, ^Bash$, mcp__.* | No (none match) |
| PostToolUse | .* (session-length-warning.sh) | Yes, but async: true + always exit 0 = non-blocking |
| Stop | stop-false-claim-detector.sh | Has explicit permission_mode == "plan" bypass |
No hook can block or interfere with ExitPlanMode.
Reproduction
- Start Claude Code v2.1.72 (
claude) - Enter plan mode via
/planor Shift+Tab - Give a non-trivial task (e.g., "refactor the authentication module")
- Wait for plan to be presented
- Approve the plan
- Expected: Mode transitions to previous mode, implementation begins
- Actual: Model asks "what do you want to change?" in a loop. Mode stays in plan.
- Workaround: Press Shift+Tab during the loop to force-exit
Happens intermittently (~30-50% of plan mode entries in my testing). More frequent with:
- Longer plans (>5K chars)
- Sessions that have used
/clear - Sessions with context compaction
Environment
- Version: 2.1.72
- Platform: Linux (Ubuntu), x86_64
- Terminal: Various (issue is platform-independent per related reports)
- Binary: ELF 64-bit LSB executable, compiled with bundled JS
Related Issues
- #32923 — Same symptom (plan mode doesn't exit after approval), filed 2026-03-10
- #32934 — ExitPlanMode fails with
--dangerously-skip-permissions - #32868 — Plan mode doesn't work after
/clear - #29725 — Plan mode breaks CLI entirely (messages stop reaching model)
- #26651 — Enqueue/dequeue spin loop after ExitPlanMode (likely same root cause)
- #26520 — No defer/abandon option traps users in plan mode
- #27066 — ExitPlanMode never called after Write tool
- #29067 — ExitPlanMode denial cascades to subsequent tool calls
- #23754 — ExitPlanMode rejection causes session restart
These 9 open issues all stem from the same fragile state machine. A robust fix to the setAppState race condition would likely resolve most of them.
Methodology
The decompiled code was extracted using strings and grep -oaP on the compiled binary at ~/.local/share/claude/versions/2.1.72. Variable names are minified but the logic structure, string literals, and control flow are preserved. The analysis focuses on the ExitPlanModeV2Tool implementation and its interaction with toolPermissionContext.mode.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗