[Bug] Session state inconsistency after /rewind and /resume with file state mismatch
Title
/rewind is discarded on resume if you exit without sending a message — session comes back at the pre-rewind head
Environment
- Claude Code CLI 2.1.234 (native installer,
~/.local/share/claude/versions/2.1.234) - macOS (Darwin 25.5.0), arm64
- Default JSONL transcript storage under
~/.claude/projects/<project>/<session>.jsonl
Steps to reproduce
- In a session with several turns, run
/rewindand pick an earlier checkpoint (restoring conversation, with or without code). - Do not type anything afterwards. Exit immediately (
/exit, Ctrl+C ×2 — any exit). - Reopen and
/resumethe same session.
Expected
The conversation resumes at the checkpoint chosen in step 1; the rewound-away turns are not part of the active context.
Actual
The full pre-rewind conversation is back — every turn that /rewind had removed is in context again, as if the rewind never happened.
The bug only occurs when nothing is sent after the rewind. Sending any message (even ok) before exiting makes the rewind survive, because the new message is appended with parentUuid = the rewind target and becomes the newest leaf on its own.
Why this is worse than extra history
/rewind restores files to disk immediately, and that part is durable. So after rewind → exit → resume you get a mismatched state: files at the rewound-back revision, conversation at the pre-rewind head. On resume the model believes edits exist that are no longer on disk. That divergence is considerably harder to notice than a few stale turns.
Analysis
Symbol names below are mangled — they come from reading the compiled 2.1.234 bundle, not from source — but the shape of the seam is clear.
The transcript is append-only; "where the conversation head is" is expressed by {type:"last-prompt", leafUuid:…} pointer records. /rewind does persist a pointer, and even flags it:
appendEntry({
type: "last-prompt",
leafUuid: <rewind target>,
explicit: true,
...(opts?.rewound && { rewound: true }),
sessionId: …
})
The loader then computes two things from the log. A scalar pass tracks the last pointer plus the rewound flag and derives the anchor correctly:
else if (ee.type === "last-prompt") {
if (ee.leafUuid)
z = ee.explicit === true || (z && ee.leafUuid === q),
U = ee.rewound === true || (U && ee.leafUuid === q),
q = ee.leafUuid, J = false;
…
}
// …
{ leafUuids: le, clearedToEmpty: !opts?.keepAllLeaves && J, rewindAnchorUuid: U ? q : void 0 }
But the message chain is reconstructed from leafUuids, a Set that accumulates every leafUuid ever written, by taking the latest matching message:
let J = Mur(i.values(), K => leafUuids.has(K.uuid) && (K.type === "user" || K.type === "assistant"));
A rewind target is an older message, while the pre-rewind head is still in that Set and is newer — so it wins, and the entire pre-rewind chain is rebuilt. (The --continue-style "most recent leaf" helper picks by max(timestamp) over the same Set, with the same outcome.) rewindAnchorUuid is passed separately to the message-processing step, which cannot undo a chain that was already selected from the wrong leaf.
So: rewind persistence is fine; leaf selection on load ignores both the recency of the pointer records and the rewound marker.
Suggested fix
When the last last-prompt pointer carries rewound: true, honor that pointer as the head instead of picking the newest member of the accumulated leafUuids set — i.e. let the scalar pointer that already produces rewindAnchorUuid drive chain reconstruction, rather than the Set.
Workaround
Send any message after /rewind before exiting.
Two notes on confidence: the root-cause section is read off the compiled bundle (windows around the relevant symbols, not whole function bodies), and I have not run the repro myself — the steps are yours. If you'd rather keep the issue short, everything from Analysis down can be dropped without losing the report.