Bug: --resume loads 0% context on v2.1.91 — three regressions in session loading pipeline (source code verified)
Summary
Session resume (both /resume in the REPL and --resume CLI flag) on v2.1.91 silently loads 0% of conversation history. The model has zero context — not a rendering issue, but actual context loss. Each failed resume attempt corrupts the session JSONL further by forking the parentUuid DAG. Source code analysis reveals three v2.1.91-specific regressions in the session loading pipeline.
We built a Go tool (ccdiag) to recover session context directly from JSONL files.
Environment
- Claude Code v2.1.91 (npm/Node installation, Windows 10)
- Opus 4.6, 1M context
- Session: 20 MB, 5,027 lines, 444 user messages
Important distinction: this is NOT the scrollback rendering bug
Since v2.1.89, terminal scrollback doesn't render properly (#41814, #42024). But on v2.1.89-90 resumes, the context window was still fully loaded — the model remembered the conversation, you just couldn't scroll back to see it visually.
On v2.1.91, resume fails to load context entirely. We used /resume inside the REPL (showed session list, loaded it), then tried claude --resume <id> from CLI — same result. /context confirms:
Messages: 644 tokens (0.1%)
37k/1000k tokens (4%)
The model responds: "context of previous conversation was lost (new session)". This is a context loading regression, not a rendering issue.
Downgrading to v2.1.84 produces the same result — the JSONL was already corrupted by prior resume attempts on v2.1.91.
Root cause analysis (source code verified)
We analyzed the full source code (v2.1.88 TypeScript from the leaked source map) and compared with the v2.1.91 minified cli.js. Three changes in v2.1.91 contribute to the regression:
1. New synchronous reader BxY skips fork pruning (files >5 MB)
v2.1.88 (sessionStorage.ts:3520-3578):
// For files >5MB:
const scan = await readTranscriptForLoad(filePath, size) // async
buf = walkChainBeforeParse(buf) // PRUNES dead fork branches
const entries = parseJSONL(buf)
v2.1.91 (cli.js offset ~11454550):
// For files >5MB:
let b = BxY(q, R, V, ()=>{_.clear(), W.clear(), k.clear()}) // SYNC, inline processing
return JQK(_), MQK(_), y() // EARLY RETURN — walkChainBeforeParse SKIPPED entirely
Verified: walkChainBeforeParse appears 2 times in v2.1.88 source, 0 times in v2.1.91 cli.js — the function was removed. Our 20 MB file triggers this >5 MB path, and fork branches are no longer pruned.
2. New timestamp fallback ExY can bridge fork boundaries
v2.1.88 (sessionStorage.ts:2088-2090):
currentMsg = currentMsg.parentUuid
? messages.get(currentMsg.parentUuid)
: undefined
// Parent not found → walk terminates cleanly
v2.1.91 (cli.js offset ~11439744):
let O = q.get($) // lookup parent by UUID
if(!O) {
if(O = ExY(q,Y,z), O) // NEW: timestamp fallback (5-second window)
d("tengu_chain_timestamp_fallback",{})
}
Verified: tengu_chain_timestamp_fallback appears 1 time in v2.1.91 cli.js, 0 times in v2.1.88 source — this is entirely new. The ExY function searches for any message within 5 seconds of the current timestamp, regardless of session branch. This can incorrectly bridge across fork boundaries, connecting unrelated messages.
3. Missing leafUuids check in getLastSessionLog (pre-existing)
loadFullLog (sessionStorage.ts:2988) — correct:
findLatestMessage(messages.values(),
msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant'))
getLastSessionLog (sessionStorage.ts:3900) — missing check:
findLatestMessage(messages.values(), m => !m.isSidechain)
// ↑ No leafUuids check! Can pick a mid-chain synthetic message from a failed resume
This pre-existing bug becomes critical with DAG forks: findLatestMessage picks a synthetic message from the most recent resume attempt (by timestamp) instead of the actual conversation leaf.
How the fork corrupts the JSONL
Each resume appends synthetic messages via recordTranscript (called by useLogMessages hook on first REPL render). Multiple messages end up sharing parentUuid = msg444.uuid:
root → msg1 → ... → msg444 (original: 444 messages, 20 MB)
├→ synthetic_1 → hook_1 (resume #1: 2 messages)
├→ synthetic_2 → hook_2 (resume #2: 2 messages)
└→ synthetic_3 → hook_3 (resume #3: 2 messages)
getLastSessionLog without leafUuids → picks synthetic_3 (newest timestamp) → buildConversationChain walks: synthetic_3 → msg444 → ... → root. But with walkChainBeforeParse removed in v2.1.91, the reader loads ALL entries including forks, and the chain walk may terminate early or bridge incorrectly via ExY.
Additional findings
- Multiple resume attempts make it worse — each attempt adds another fork branch to the JSONL. This is not documented anywhere. Resume should be a read-only operation.
/exportshows full context while API receives truncated version — see ArkNill's cache analysis documenting silent microcompact (327 events) and tool result budget cap (200K chars).- Financial impact: the combination of broken resume + cache invalidation + silent microcompact cost us $364.87 in extra usage in a single day (April 2) on top of a $200/month Max subscription — a $10,920/month rate.
Related issues
- #40319 — Session resume loads zero history (regression v2.1.85)
- #39856 — Context drops from 67% to 2.5% on resume
- #40487 — Duplicate UUID fork, 1,232 messages dropped
- #42376 —
--continuev2.1.90 silently drops context - #42338 — Resume invalidates prompt cache (cache_read = 0)
- #42542 — 3 silent microcompact mechanisms,
DISABLE_AUTO_COMPACTdoesn't disable all - #34629 —
deferred_tools_deltacauses resume cache miss (v2.1.69+) - #40524 — Cache invalidation on subsequent turns (74 comments, assigned to
qing-ant) - #41666 — v2.1.88 yanked release + token loss analysis
Workaround: ccdiag recover
Since Claude Code's resume is broken, we built a Go tool that reads JSONL directly:
# Install
go install github.com/kolkov/ccdiag@latest
# Recover session context
ccdiag recover session.jsonl
# Find and recover latest session for a project
ccdiag recover --latest D:\projects\myproject
# Full dump (handoff summary + all messages)
ccdiag recover --output full -o recovery.md session.jsonl
Extracts: all user/assistant messages, files modified (Write/Edit with counts), GitHub commands (classified), git commands, web searches, issue references — everything needed to reconstruct session context for a new conversation. Zero dependencies, pure Go.
Source: https://github.com/kolkov/ccdiag
Suggested fixes
Fix 1: Restore fork pruning for large files
The walkChainBeforeParse optimization was removed in v2.1.91 when BxY replaced the async reader. Restore it or integrate equivalent pruning into BxY:
// After BxY loads all entries, prune fork branches before chain walk
const leafUuids = computeLeafUuids(messages)
// Remove messages not reachable from the longest leaf chain
Fix 2: Add leafUuids check to getLastSessionLog
sessionStorage.ts:3900 — align with loadFullLog at line 2988:
// Before (broken):
const lastMessage = findLatestMessage(messages.values(), m => !m.isSidechain)
// After (correct):
const lastMessage = findLatestMessage(
messages.values(),
msg => leafUuids.has(msg.uuid) && !msg.isSidechain,
)
Fix 3: Guard ExY timestamp fallback
The 5-second window bridge should not cross session/fork boundaries:
function timestampFallback(messages, currentMsg, seen) {
for (const m of messages.values()) {
if (seen.has(m.uuid)) continue
if (m.sessionId !== currentMsg.sessionId) continue // Don't bridge forks
const delta = currentTs - ts
if (delta >= 0 && delta <= 5000 && delta < bestDelta) { bestDelta = delta; best = m }
}
return best
}
Fix 4: Make resume read-only
Resume should NEVER append to the JSONL it's loading. Synthetic messages from the resume session should go to a NEW file, not the original.
Prompts for Claude Code to fix itself
Prompt 1: Fix getLastSessionLog leaf selection
Read src/utils/sessionStorage.ts. Find the function `getLastSessionLog` (around line 3869).
On line 3900, `findLatestMessage` uses predicate `m => !m.isSidechain` WITHOUT checking
`leafUuids`. Compare with `loadFullLog` (around line 2988) which correctly uses
`msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant')`.
Fix: compute `leafUuids` in `getLastSessionLog` (same as `loadFullLog` does) and add the
`leafUuids.has(msg.uuid)` check to the `findLatestMessage` predicate on line 3900.
This prevents the chain walker from starting at a synthetic resume message instead of the
actual conversation leaf.
Prompt 2: Restore fork pruning in the new BxY reader
In v2.1.91, the function `walkChainBeforeParse` was removed when the new synchronous
inline reader (BxY) replaced `readTranscriptForLoad` for files >5MB. This means fork
branches from failed resume attempts are no longer pruned before chain walking.
Find the BxY reader (sync file reader using openSync/readSync for large JSONL files).
After it finishes loading all entries, add a pruning step that removes messages not
reachable from the longest parentUuid chain. This restores the behavior of the removed
`walkChainBeforeParse` function.
Prompt 3: Guard timestamp fallback against fork bridging
In `buildConversationChain` (sessionStorage.ts), v2.1.91 added a timestamp-based fallback
(ExY function, telemetry event "tengu_chain_timestamp_fallback") that finds the nearest
message within a 5-second window when parentUuid lookup fails.
This fallback can incorrectly bridge across fork boundaries — connecting a message from
one resume attempt to a message from a different branch. Add a guard: the fallback should
only consider messages with the same sessionId as the current message. Also skip messages
already in the `seen` set.
Prompt 4: Make resume append to a new file
When resuming a session, `useLogMessages` hook calls `recordTranscript` which appends
synthetic messages (NO_RESPONSE_REQUESTED, hook outputs) to the ORIGINAL session JSONL.
This creates forks in the parentUuid DAG that break subsequent resumes.
Fix: on resume, create a NEW continuation JSONL file (e.g., {sessionId}-{timestamp}.jsonl)
for new messages. The original file stays read-only. The session loader should scan for
continuation files and merge them in order when loading.
Prompt 5: Add resume diagnostics
Add a diagnostic check before resume: after loading the JSONL and building the conversation
chain, compare chain length with total non-sidechain messages. If the chain contains less
than 50% of messages, warn the user:
"Warning: resume loaded {chain_length} of {total_messages} messages ({percentage}%).
The session may be corrupted by fork branches. Use `ccdiag recover` to extract full context."
Log the discrepancy to telemetry as "tengu_resume_chain_truncation".
Workaround: ccdiag — session analyzer, recovery & proxy
Until these bugs are fixed, we built a Go CLI tool that bypasses the broken resume logic:
go install github.com/kolkov/ccdiag@latest
# Recover lost session context (reads JSONL directly, not via broken walker)
ccdiag recover --latest D:\projects\myproject
ccdiag recover --output full -o recovery.md session.jsonl
# Real-time API traffic proxy — detect cache invalidation, token burn, silent fallback
ccdiag proxy --verbose
# .claude/settings.local.json: { "env": { "ANTHROPIC_BASE_URL": "http://localhost:9119" } }
ccdiag proxy stats --last 1h --cost
The proxy logs per-session JSONL files (session UUID = file name) with token usage, cache ratio, cost, and latency for every API request. Makes it trivial to detect watchdog false positives (99% cache ratio = model was thinking, not hung), cache invalidation after resume (#42338), and silent 529 fallback.
Zero dependencies, Go stdlib only: https://github.com/kolkov/ccdiag
Expected behavior
- Warn when context is truncated — don't silently drop 99.9% of history
- Don't corrupt the JSONL on failed resume — read-only operation
- Follow the longest chain — not the most recent fork
- Don't bridge forks via timestamp fallback
- Provide a built-in recovery tool — users shouldn't need third-party tools to access their own session data
---
cc: @bcherny @ant-kurt @fvolcic @bogini @ashwin-ant @OctavianGuzu @whyuan-cc @hackyon-anthropic @chrislloyd @catherinewu @ThariqS @rboyce-ant @dicksontsai @dhollman @qing-ant @ddworken @km-anthropic @sid374
This issue has 10 comments on GitHub. Read the full discussion on GitHub ↗