fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows) - #215
Conversation
…ast Sessions
Automated tools (CI review bots, etc.) invoke Claude Code via the SDK
and write their transcripts into the same ~/.claude/projects tree as
real interactive sessions, but were never something a user can resume
into -- no PTY, no running process. Their one-shot review prompts also
embed the full diff inline as a single message, often exceeding the
16KB head / 32KB tail windows this scanner reads, so they cluttered
Past Sessions two ways: as blank rows when the huge message couldn't
be parsed, or as N identical "Review this change for security
vulnerabilities..." rows when it could.
Claude Code stamps `entrypoint` on its own message records ('cli' for
a real interactive session, e.g. 'sdk-py' for an SDK invocation).
Exclude any transcript whose entrypoint isn't 'cli' from the history
list entirely, checked last so it reuses whatever head/tail the prompt
extraction already read. Missing entrypoint (older transcripts) reads
as interactive -- fail open, matching every other gating check in this
codebase. Shared by /api/history/sessions and /api/sessions/unified,
since both call the same scanProjectDir().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>…cross-contaminating history rows COD-140's backfill was meant to cover live/persisted rows whose Codeman id doesn't match an on-disk transcript UUID, guessing from the newest transcript in the same workingDir as a last resort. It was also firing for pure history rows whose OWN transcript scan already ran (and genuinely found nothing, e.g. an oversized first message) -- those got silently backfilled with the newest OTHER session's opening line from the same directory. Not a blank row, but actively wrong: old sessions displayed a completely unrelated (often today's live) conversation's first prompt as if it were their own. Skip the workingDir guess for any item that already has its own 'history' source -- it already had a real, direct attempt. Rows with no history source at all (their transcript isn't linked/scanned under their own id yet) still get the guess, matching the original intent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…okkeeping Blank firstPrompt rows weren't all oversized messages -- traced one directly: a session restarted many times (mux deaths, redeploys) accumulates a batch of small bookkeeping lines (mode/permission-mode/ last-prompt/queue-operation, one batch per restart) ahead of the real first message. With enough restarts these alone crossed the old 16KB head-read window, so extraction found nothing even though the actual first message was tiny (measured case: ~17.5KB of bookkeeping pushed a 189-byte real message just past the boundary). Raise the head buffer from 16KB to 128KB (matching the existing precedent at the codex-history head-read a few hundred lines up) and fix three now-stale `> 16384`/`> 65536` fallback thresholds to reference headBuf.length instead of hardcoded numbers, so the tail-read fallbacks stay correctly scoped to "beyond what head already covered." Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erage Two follow-ups from reviewing the entrypoint-filter and head-buffer fixes before submitting them upstream: 1. extractTranscriptEntrypoint() scanned any line containing the substring "entrypoint", not specifically the first "type":"user"/ "type":"assistant" message line (unlike its sibling extractFirstUserPrompt, which does scope to type). A transcript that started under an older Claude Code version (no entrypoint field) and got resumed under a newer one mid-conversation could pick up the field from a much later message than the true first one, misattributing the session's origin. Scoped it to match. 2. Added a regression test proving the tail-read fallback still engages correctly when bookkeeping accumulation exceeds even the new 128KB head window, not just the 16KB it previously blanked at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ession test The comment implied the fallback could be "silently skipped" by the stale hardcoded threshold, which isn't actually true -- the old smaller numbers were always more eager to trigger the fallback, never less (same correction as the commit this test belongs to). What the test actually protects against is the fallback logic itself breaking (e.g. a copy-paste slip dropping the check entirely), not the exact threshold value. Reworded to say that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ier head read extractTranscriptEntrypoint returned the FIRST entrypoint-bearing message's value instead of scanning for any 'cli' occurrence, so a transcript that started under an older Claude Code build (no entrypoint field) and later picked up a non-'cli' entrypoint on some later message was wrongly excluded from history — the opposite of the fail-open behavior the function's own comment claimed. Now returns 'cli' the moment any scanned message carries it, and only falls back to a non-cli value when nothing else qualifies. Head/tail entrypoints are merged the same way (either side being 'cli' wins). Also restructures scanProjectDir's head read into two tiers: try 16KB first and escalate to 128KB only when that wasn't enough, instead of reading 128KB for every file unconditionally. Measured against a real ~/.claude/projects tree, the unconditional-128KB version roughly quadrupled scan cost to fix a problem only a minority of files actually have; the two-tier version cuts bytes read by ~36% and wall time by ~17% while producing identical output. Also fixes a fallback regression where a failed head read (e.g. EMFILE) on a file at or under the head buffer size no longer got a shot at the tail-read fallback, silently dropping the session from history.
Uh oh!
There was an error while loading. Please reload this page.
…not cli" #215 filters non-interactive transcripts out of Past Sessions with `entrypoint !== 'cli'`. That is an allowlist on a value, and the check hides rows, so it fails CLOSED on anything Claude Code has not shipped yet: the day it stamps a new interactive entrypoint (a rename, or a second interactive host), no transcript matches 'cli' any more and the entire Past Sessions list goes blank with nothing in the UI explaining why. Invert it to a blocklist on the SDK shape (`sdk`, `sdk-cli`, `sdk-py`). An automated entrypoint we do not recognize yet now costs a few noisy rows, which is the annoyance the filter set out to fix, rather than a dead feature. Matches the fail-open reasoning #215 already applied to a MISSING entrypoint field; only the unknown-VALUE case was inverted. Test fails against the pre-fix line and passes after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ark0N
commented
Aug 5, 2026
Merged, thank you! 🙏 Three real bugs, each with the reasoning shown, and the two-pass history on #1 and #3 (fixing the first attempt after review caught the "first field wins" misattribution and the 4x scan cost) is exactly the kind of iteration I like to see in a PR description rather than hidden in force-pushes. The two-tier 16KB→128KB read is a genuinely better answer than either extreme. Verified here against real data before merging:
One follow-up I pushed on top ( The exclusion shipped as This is the same fail-open reasoning your own comment applies to a missing entrypoint field ("matching every other gating check in this codebase"); only the unknown-value case was inverted. Added a test that fails against the original line. No change to behavior on any entrypoint value that actually exists today, so your verification still holds. Thanks for the thorough work on this one. |
Summary
Three data-quality bugs in the Past Sessions list (Cmd+K Session Manager / phone overview PAST SESSIONS), all rooted in
scanProjectDir()/extractTranscriptEntrypoint()insession-routes.tsandmergeUnifiedSessions()inunified-session-service.ts. Bundled together since they touch the same history-scanning path and were found while investigating the same user-facing symptom (a noisy, sometimes-wrong session list).1. Automated/SDK-driven transcripts cluttering history
Claude Code stamps an
entrypointfield on message records:'cli'for a real interactive session,'sdk-py'/'sdk-cli'for an SDK-driven one (CI review bots, automated tooling, etc.). Those were showing up in Past Sessions indistinguishably from real sessions a user could actually resume into — they never were resumable, so they were pure noise.extractTranscriptEntrypoint()now scans a transcript's"type":"user"/"type":"assistant"lines for the entrypoint field and excludes the transcript only when every entrypoint-bearing message is non-'cli'. On my own~/.claude/projects(321 transcripts as of this PR), that's 257 excluded (80%) — 76sdk-cli, 178sdk-py, 3 with no entrypoint field at all (left visible, fail-open). This is a hard exclusion, not a toggle — deliberately: these transcripts were never something a user could resume into, so there's no legitimate "show me the automated ones too" use case in this list. Fully scoped to Claude Code transcripts only; the other four CLI backends (OpenCode, Codex, Gemini, Antigravity) have their own separate history-reading code paths, untouched by this change (confirmed by researching each one's actual on-disk session format before writing this).The detection logic itself went through a second pass after an independent review caught a real bug: the original version returned the first entrypoint-bearing message's value rather than scanning all of them, so a transcript that started under an older Claude Code build (no entrypoint field on its true first message) and later picked up a non-
'cli'entrypoint on some later message was wrongly excluded — the opposite of the intended fail-open behavior. It now returns'cli'the moment any scanned message carries it.2. Cross-contaminated firstPrompt/lastPrompt on resumed sessions
mergeUnifiedSessions()'s workingDir-based backfill (used to recover afirstPrompt/lastPromptfor a session row whose own transcript extraction came up empty) didn't check whether the row already had its own history entry. A resumed session with its own aliased transcript could have its real preview text overwritten by a same-directory sibling's — showing the wrong conversation's content next to the right session. Fixed by skipping the backfill whenever the row already has an entry from its ownhistorysource; borrowing only ever applies to rows with no history entry of their own.3. Blank rows from restart-bookkeeping accumulation, plus a real perf fix
Every session restart writes a small batch of bookkeeping-only lines (
mode,permission-mode,last-prompt,queue-operation) ahead of the next real message. A session restarted many times over a long conversation accumulates enough of these that the original 16KB head-read window landed entirely on bookkeeping, producing a blank row.This went through two iterations:
~/.claude/projectstree (320 files): 36% fewer bytes read, ~17.5% faster wall time than the unconditional-128KB version, with identical output.Also fixes a fallback regression introduced during that same rework: a failed head read (e.g.
EMFILEwhile scanning hundreds of files) on a file at or under the head-buffer size no longer got a shot at the tail-read fallback, silently dropping the session from history instead of giving it a second chance.Testing
test/routes/session-routes.test.ts(87 tests) — includes new coverage discriminating the "any cli wins" vs "first field wins" entrypoint logic (verified to fail against the pre-fix version), and the restart-bookkeeping head-window case.test/services/unified-session-service.test.ts(28 tests) — includes new coverage for the resumed-session cross-contamination case (verified to fail against the pre-fix version).tsc --noEmitclean,npm run lintclean.curlrequests, before/after) as well as the automated suite.