From e888c65c52faae4725812e433dda46756a78f6c6 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 21:15:30 -0500 Subject: [PATCH 1/6] fix(history): exclude non-interactive (SDK-driven) transcripts from Past 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 --- src/web/routes/session-routes.ts | 40 ++++++++++++++++++++++++++ test/routes/session-routes.test.ts | 45 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index e3a1c447..d461766c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2456,6 +2456,30 @@ export function registerSessionRoutes( return undefined; } + /** + * The `entrypoint` field Claude Code stamps on its own message records: + * 'cli' for a real interactive session, something else (e.g. 'sdk-py') for + * an SDK/automated invocation. Used to exclude non-interactive transcripts + * (CI review bots, etc.) from the resumable history list — they were never + * something a user can resume into. + */ + function extractTranscriptEntrypoint(text: string): string | undefined { + let start = 0; + while (start < text.length) { + const end = text.indexOf('\n', start); + const line = end === -1 ? text.slice(start) : text.slice(start, end); + start = end === -1 ? text.length : end + 1; + if (!line.includes('"entrypoint"')) continue; + try { + const entry = JSON.parse(line); + if (typeof entry.entrypoint === 'string') return entry.entrypoint; + } catch { + // Malformed/truncated line — skip + } + } + return undefined; + } + /** * Extract the text of the LAST user message from a JSONL transcript chunk * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message @@ -2763,6 +2787,22 @@ export function registerSessionRoutes( const lastPrompt = (tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined); + // Automated/SDK-driven invocations (CI review bots, etc.) write transcripts + // into the same ~/.claude/projects tree as interactive sessions but were + // never something a user can resume into — no PTY, no running process, and + // their "conversation" is typically a single one-shot prompt (often with a + // full diff embedded, which is exactly why it dwarfs this scanner's read + // windows and shows up above as blank or as an identical boilerplate + // sentence across many rows). Checked last, so it reuses whatever `head`/ + // `tail` the prompt extraction above already read rather than triggering + // an extra file read. Missing entrypoint (older transcripts) reads as + // interactive — fail open, matching every other gating check in this + // codebase. + const entrypoint = + (head ? extractTranscriptEntrypoint(head) : undefined) ?? + (tail ? extractTranscriptEntrypoint(tail) : undefined); + if (entrypoint && entrypoint !== 'cli') continue; + out.push({ sessionId, workingDir, diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 6d1a02cc..a5ab077d 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1350,6 +1350,51 @@ describe('session-routes', () => { expect(row.workingDir).toBe(dotDir); expect(row.workingDir).not.toContain('//'); }); + + it('excludes non-interactive (SDK-driven) transcripts from the history list', async () => { + // CI review bots and other automated tools write transcripts into the same + // ~/.claude/projects tree as interactive sessions (entrypoint "sdk-py" etc.) + // but were never something a user can resume into — no PTY, no running + // process. They cluttered Past Sessions as blank rows or identical + // boilerplate ("Review this change for security vulnerabilities..."). + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-test'); + await mkdir(projPath, { recursive: true }); + + const cliId = '33333333-3333-3333-3333-333333333333'; + const sdkId = '44444444-4444-4444-4444-444444444444'; + const noEntrypointId = '55555555-5555-5555-5555-555555555555'; + + const cliLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real question' } }) + + '\n'; + const sdkLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'Review this change for security vulnerabilities.' }, + }) + '\n'; + // Older transcripts predate the entrypoint field entirely — must still show. + const noEntrypointLine = + JSON.stringify({ type: 'user', message: { role: 'user', content: 'a pre-entrypoint session' } }) + '\n'; + + await writeFile(join(projPath, `${cliId}.jsonl`), cliLine + '#'.repeat(4200 - cliLine.length)); + await writeFile(join(projPath, `${sdkId}.jsonl`), sdkLine + '#'.repeat(4200 - sdkLine.length)); + await writeFile( + join(projPath, `${noEntrypointId}.jsonl`), + noEntrypointLine + '#'.repeat(4200 - noEntrypointLine.length) + ); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).toContain(cliId); + expect(ids).toContain(noEntrypointId); + expect(ids).not.toContain(sdkId); + }); }); // ========== POST /api/sessions (with resumeSessionId) ========== From a2aed38073e96aa4323adeb5aa73bdfc4ee674f4 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 21:28:32 -0500 Subject: [PATCH 2/6] fix(unified-sessions): stop the firstPrompt workingDir backfill from 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 --- src/services/unified-session-service.ts | 15 +++++++-- test/services/unified-session-service.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 5d4fa134..6eaf5545 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte // different UUID. Backfill from the already-passed history: first try the claudeSessionId // join, then the newest transcript in the same workingDir. Never overwrite a non-empty // firstPrompt (so rows keyed to their own transcript are untouched). + // + // The workingDir guess is a last resort and MUST be skipped for any item that + // already has its own 'history' entry (step 1 above already gave it a real, + // direct scan of its own transcript). Without this guard, a history row whose + // OWN extraction genuinely failed (oversized first message, etc.) silently + // inherited the newest OTHER session's opening line from the same directory — + // not a blank, but actively wrong: old sessions displayed today's conversation + // as if it were their own. A row with no 'history' source at all (its + // transcript hasn't been linked/scanned under its own id yet) has no such + // direct attempt to prefer, so the guess remains a reasonable stand-in there. const firstPromptByUuid = new Map(); const firstPromptByWorkingDir = new Map(); // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). @@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte } } for (const item of map.values()) { + const hasOwnHistoryEntry = item.sources.includes('history'); if (!item.firstPrompt) { // never overwrite an existing non-empty prompt const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.firstPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = firstPromptByWorkingDir.get(item.workingDir); if (byDir) item.firstPrompt = byDir.prompt; } @@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.lastPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = lastPromptByWorkingDir.get(item.workingDir); if (byDir) item.lastPrompt = byDir.prompt; } diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index f25faff7..2d6e1db8 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -270,6 +270,39 @@ describe('mergeUnifiedSessions', () => { expect(live!.firstPrompt).toBeUndefined(); }); + it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => { + // A pure history row already got its own real scan (step 1 keys it under its + // OWN sessionId) — if that extraction genuinely failed (oversized first + // message, noise-filtered, etc.), the workingDir guess must not paper over + // it with an unrelated session's opening line. Regression: an old session + // in a shared workingDir was displaying TODAY's live session's firstPrompt + // as its own, because the guess didn't check whether this row already had + // its own (failed) attempt. + const merged = mergeUnifiedSessions({ + history: [ + // This session's own transcript scan found no usable prompt. + { + sessionId: 'old-uuid', + workingDir: '/shared', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: undefined, + }, + // A much newer, unrelated session in the same directory. + { + sessionId: 'newer-uuid', + workingDir: '/shared', + sizeBytes: 6000, + lastModified: '2026-06-01T00:00:00.000Z', + firstPrompt: "today's real prompt", + }, + ], + }); + const old = merged.find((m) => m.sessionId === 'old-uuid'); + expect(old).toBeDefined(); + expect(old!.firstPrompt).toBeUndefined(); + }); + // COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the // most-recent user prompt also reaches live rows whose id ≠ transcript UUID. it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => { From 18b473f0e4ec0964dad4c959b5634c2b08e6cde2 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 21:36:38 -0500 Subject: [PATCH 3/6] fix(history): raise the transcript head-read window to fit restart bookkeeping 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 --- src/web/routes/session-routes.ts | 22 +++++++++++++------ test/routes/session-routes.test.ts | 34 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d461766c..9b6e9749 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2760,7 +2760,7 @@ export function registerSessionRoutes( let foundContent = head ? hasConversation(head) : false; let tail: string | null = null; - if (!foundContent && fileStat.size > 16384) { + if (!foundContent && fileStat.size > headBuf.length) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); if (tail) foundContent = hasConversation(tail); @@ -2768,7 +2768,7 @@ export function registerSessionRoutes( if (!foundContent) continue; if (head) firstPrompt = extractFirstUserPrompt(head); - if (!firstPrompt && fileStat.size > 65536) { + if (!firstPrompt && fileStat.size > headBuf.length) { if (!tail) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); @@ -2778,9 +2778,9 @@ export function registerSessionRoutes( // COD-145: last (most recent) user prompt lives near the END of the file, so // prefer the tail. For large files where no tail was read yet, read one - // (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which - // then contains the whole transcript — scan it for the last match instead. - if (!tail && fileStat.size > 65536) { + // (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`, + // which then contains the whole transcript — scan it for the last match instead. + if (!tail && fileStat.size > headBuf.length) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); } @@ -2819,7 +2819,14 @@ export function registerSessionRoutes( app.get('/api/history/sessions', async (req) => { const query = req.query as { projectKey?: string; offset?: string; limit?: string }; const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - const headBuf = Buffer.alloc(16384); + // 128KB (was 16KB): a session restarted many times over the course of a long + // conversation accumulates small bookkeeping lines (mode/permission-mode/ + // last-prompt/queue-operation, one batch per restart) ahead of the real first + // message. 16KB was enough margin for a handful of restarts but not dozens — + // a genuinely tiny first message still came up blank because the metadata + // alone crossed the window. 128KB matches the existing precedent elsewhere in + // this file (line ~1431) rather than inventing a new size. + const headBuf = Buffer.alloc(131072); // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin // must only see history whose decoded workingDir is inside their own case space. // Do NOT trust the caller-supplied projectKey — confine on the decoded path. @@ -2926,7 +2933,8 @@ export function registerSessionRoutes( const history: HistoryInput[] = []; try { const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - const headBuf = Buffer.alloc(16384); + // 128KB (was 16KB) — see the sibling allocation above for why. + const headBuf = Buffer.alloc(131072); const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index a5ab077d..970a26e7 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1395,6 +1395,40 @@ describe('session-routes', () => { expect(ids).toContain(noEntrypointId); expect(ids).not.toContain(sdkId); }); + + it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => { + // A session restarted many times over a long conversation accumulates a batch + // of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation) + // per restart, ahead of the real first message. With enough restarts these can + // push the genuine first prompt past a 16KB head-read window even though the + // message itself is tiny — the row showed up blank despite having real content. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-bookkeeping-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '66666666-6666-6666-6666-666666666666'; + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n'; + // > 16KB (the old head-read size) but well under 128KB (the new one). + const prefix = bookkeepingLine.repeat(Math.ceil(20000 / bookkeepingLine.length)); + const realLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'the real first message' }, + }) + '\n'; + expect(prefix.length).toBeGreaterThan(16384); + + await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-bookkeeping-test', + }); + expect(res.statusCode).toBe(200); + const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.firstPrompt).toBe('the real first message'); + }); }); // ========== POST /api/sessions (with resumeSessionId) ========== From 251706be3b6af920346e720831af71dcad77bc05 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 21:56:00 -0500 Subject: [PATCH 4/6] harden: scope entrypoint detection to message lines, add fallback coverage 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 --- src/web/routes/session-routes.ts | 12 ++++- test/routes/session-routes.test.ts | 76 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9b6e9749..7c1e733a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2462,6 +2462,13 @@ export function registerSessionRoutes( * an SDK/automated invocation. Used to exclude non-interactive transcripts * (CI review bots, etc.) from the resumable history list — they were never * something a user can resume into. + * + * Scoped to `"type":"user"`/`"type":"assistant"` lines specifically, mirroring + * `extractFirstUserPrompt`'s type check, rather than any line that happens to + * contain the substring "entrypoint". A transcript that started under an older + * Claude Code version (no entrypoint field) and got resumed under a newer one + * mid-conversation could otherwise pick up the field from a much later message + * than the true first one, misattributing the session's origin. */ function extractTranscriptEntrypoint(text: string): string | undefined { let start = 0; @@ -2469,10 +2476,13 @@ export function registerSessionRoutes( const end = text.indexOf('\n', start); const line = end === -1 ? text.slice(start) : text.slice(start, end); start = end === -1 ? text.length : end + 1; + if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue; if (!line.includes('"entrypoint"')) continue; try { const entry = JSON.parse(line); - if (typeof entry.entrypoint === 'string') return entry.entrypoint; + if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') { + return entry.entrypoint; + } } catch { // Malformed/truncated line — skip } diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 970a26e7..31734a5a 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1396,6 +1396,44 @@ describe('session-routes', () => { expect(ids).not.toContain(sdkId); }); + it('attributes entrypoint from the true first message, not a later one or a bookkeeping line', async () => { + // A transcript that started under an older Claude Code version (no + // entrypoint field) and got resumed under a newer one mid-conversation + // could otherwise pick up entrypoint from a later message, misattributing + // the session's origin. Scanning must anchor on "type":"user"/"assistant" + // lines specifically, not any line that happens to mention "entrypoint". + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-scoping-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '77777777-7777-7777-7777-777777777777'; + // True first message: no entrypoint field (old-version transcript). + const firstLine = + JSON.stringify({ type: 'user', message: { role: 'user', content: 'the genuine first message' } }) + '\n'; + // A bookkeeping line that (hypothetically) mentions entrypoint outside a + // real message record — must not be mistaken for message metadata. + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n'; + // A later message, after the resume, that DOES carry entrypoint: 'cli' — + // this is what the (fixed) scan should find, since it's the first + // user/assistant line that actually carries the field. + const laterLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a later message' } }) + + '\n'; + + // scanProjectDir skips files under 4000 bytes. + const body = firstLine + bookkeepingLine + laterLine; + await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-scoping-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + // entrypoint: 'cli' (from the later message) — shown, not excluded. + expect(ids).toContain(sessionId); + }); + it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => { // A session restarted many times over a long conversation accumulates a batch // of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation) @@ -1429,6 +1467,44 @@ describe('session-routes', () => { expect(row).toBeDefined(); expect(row.firstPrompt).toBe('the real first message'); }); + + it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => { + // Raising the head buffer to 128KB helps most restart-heavy sessions, but an + // even more extreme case (many more restarts) can still exceed it. The + // existing tail-read fallback must stay correctly wired to the new + // threshold (headBuf.length, not the old hardcoded 65536) rather than being + // silently skipped because the size comparison no longer means what it used + // to. The real message here sits near the end of the file, well inside the + // 32KB tail window, so a working fallback finds it; a broken one leaves the + // row blank exactly like the bug this whole fix addresses. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '88888888-8888-8888-8888-888888888888'; + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n'; + // Comfortably past the new 128KB head window (was 16KB), so the head read + // never reaches a single "type":"user"/"assistant"/"summary" line. + const prefix = bookkeepingLine.repeat(Math.ceil(140000 / bookkeepingLine.length)); + const realLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'found via tail fallback' }, + }) + '\n'; + expect(prefix.length).toBeGreaterThan(131072); + + await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-tail-fallback-test', + }); + expect(res.statusCode).toBe(200); + const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.firstPrompt).toBe('found via tail fallback'); + }); }); // ========== POST /api/sessions (with resumeSessionId) ========== From 09f5f280174cf4db56ce2953245b6ed5dce1de5d Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 5 Aug 2026 08:59:32 -0500 Subject: [PATCH 5/6] docs(test): correct an overclaiming comment in the tail-fallback regression 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 --- test/routes/session-routes.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 31734a5a..4d85cf86 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1470,13 +1470,15 @@ describe('session-routes', () => { it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => { // Raising the head buffer to 128KB helps most restart-heavy sessions, but an - // even more extreme case (many more restarts) can still exceed it. The - // existing tail-read fallback must stay correctly wired to the new - // threshold (headBuf.length, not the old hardcoded 65536) rather than being - // silently skipped because the size comparison no longer means what it used - // to. The real message here sits near the end of the file, well inside the - // 32KB tail window, so a working fallback finds it; a broken one leaves the - // row blank exactly like the bug this whole fix addresses. + // even more extreme case (many more restarts) can still exceed it. This + // proves the tail-read fallback itself is intact after the threshold + // rewrite (`fileStat.size > headBuf.length` replacing the old hardcoded + // 16384/65536) — the fallback's own logic, not the exact threshold value, + // is what could have silently broken (e.g. a copy-paste slip that dropped + // the `> headBuf.length` check entirely). The real message sits near the + // end of the file, well inside the 32KB tail window, so a working fallback + // finds it; a broken one leaves the row blank exactly like the bug this + // whole fix addresses. const home = process.env.HOME as string; const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test'); await mkdir(projPath, { recursive: true }); From 8300c15cbdc5cf964f6a0d5055506cc662d89ff0 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 5 Aug 2026 09:56:00 -0500 Subject: [PATCH 6/6] fix(history): entrypoint detection was first-field-wins, plus a two-tier head read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/web/routes/session-routes.ts | 96 ++++++++++++----- test/routes/session-routes.test.ts | 101 ++++++++++++++---- test/services/unified-session-service.test.ts | 36 +++++++ 3 files changed, 185 insertions(+), 48 deletions(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7c1e733a..afc7ec6a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2463,15 +2463,20 @@ export function registerSessionRoutes( * (CI review bots, etc.) from the resumable history list — they were never * something a user can resume into. * - * Scoped to `"type":"user"`/`"type":"assistant"` lines specifically, mirroring - * `extractFirstUserPrompt`'s type check, rather than any line that happens to - * contain the substring "entrypoint". A transcript that started under an older - * Claude Code version (no entrypoint field) and got resumed under a newer one - * mid-conversation could otherwise pick up the field from a much later message - * than the true first one, misattributing the session's origin. + * Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint + * field — not just the first one — and returns 'cli' the moment ANY of them + * carries it. A transcript is excluded only when every entrypoint-bearing + * message says something else; "first field wins" would misattribute a + * transcript that started under an older Claude Code version (no entrypoint + * on its true first message) and later picked up a non-'cli' entrypoint on + * some later message, wrongly hiding a genuinely interactive session. This + * deliberately errs toward keeping a session visible: one real interactive + * message anywhere is enough. Returns undefined ("unknown", fail-open) only + * when nothing scanned carries the field at all. */ function extractTranscriptEntrypoint(text: string): string | undefined { let start = 0; + let sawNonCli: string | undefined; while (start < text.length) { const end = text.indexOf('\n', start); const line = end === -1 ? text.slice(start) : text.slice(start, end); @@ -2481,13 +2486,14 @@ export function registerSessionRoutes( try { const entry = JSON.parse(line); if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') { - return entry.entrypoint; + if (entry.entrypoint === 'cli') return 'cli'; + sawNonCli ??= entry.entrypoint; } } catch { // Malformed/truncated line — skip } } - return undefined; + return sawNonCli; } /** @@ -2702,7 +2708,7 @@ export function registerSessionRoutes( return finalExists ? current : process.env.HOME || '/tmp'; } - /** Read the first 16KB of a file for content sniffing. */ + /** Read the first `buf.length` bytes of a file for content sniffing. */ async function readFileHead(path: string, buf: Buffer): Promise { try { const fd = await fs.open(path, 'r'); @@ -2745,7 +2751,12 @@ export function registerSessionRoutes( // Scan a single project directory and return all valid history sessions in it. // Reused by both the global overview and the single-folder drill-down. - async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise { + async function scanProjectDir( + projPath: string, + projDir: string, + smallHeadBuf: Buffer, + headBuf: Buffer + ): Promise { const out: HistorySession[] = []; const stat = await fs.stat(projPath).catch(() => null); if (!stat?.isDirectory()) return out; @@ -2763,22 +2774,45 @@ export function registerSessionRoutes( if (!fileStat) continue; if (fileStat.size < 4000) continue; - let firstPrompt: string | undefined; - const head = await readFileHead(filePath, headBuf); const hasConversation = (text: string) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"'); + // Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough + // for the vast majority of transcripts -- and only escalate to the full + // headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for + // EVERY file in the directory roughly quadrupled the cost of a full scan + // (measured against a real ~/.claude/projects tree: ~4x both bytes read and + // wall time) to fix a problem only ~28% of files actually have. Escalating + // resolves the restart-bookkeeping case (the reason 128KB exists at all) + // without ever touching the tail-read fallback below for most of that 28%. + let head = await readFileHead(filePath, smallHeadBuf); let foundContent = head ? hasConversation(head) : false; + let firstPrompt = head ? extractFirstUserPrompt(head) : undefined; + if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) { + const biggerHead = await readFileHead(filePath, headBuf); + if (biggerHead) { + head = biggerHead; + if (!foundContent) foundContent = hasConversation(head); + if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head); + } + } + let tail: string | null = null; - if (!foundContent && fileStat.size > headBuf.length) { + // `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of + // files) must also get a shot at the tail, not just "file bigger than the + // head buffer". Losing this dropped the session from history entirely + // instead of giving it a second chance, for any file at or under the head + // buffer size whose head read happened to fail. + if (!foundContent && (head === null || fileStat.size > headBuf.length)) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); if (tail) foundContent = hasConversation(tail); } if (!foundContent) continue; - if (head) firstPrompt = extractFirstUserPrompt(head); - if (!firstPrompt && fileStat.size > headBuf.length) { + // firstPrompt was already attempted from head (both tiers) above; this is + // purely the tail fallback for whatever's left unresolved. + if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) { if (!tail) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); @@ -2808,9 +2842,15 @@ export function registerSessionRoutes( // an extra file read. Missing entrypoint (older transcripts) reads as // interactive — fail open, matching every other gating check in this // codebase. + // + // head and tail are checked independently and merged with "cli wins" (not + // a first-truthy-value `??` chain): a large file's head might land on a + // non-'cli' message while a real interactive message sits in the tail (or + // vice versa), and either one being 'cli' is enough to keep the session. + const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined; + const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined; const entrypoint = - (head ? extractTranscriptEntrypoint(head) : undefined) ?? - (tail ? extractTranscriptEntrypoint(tail) : undefined); + headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint); if (entrypoint && entrypoint !== 'cli') continue; out.push({ @@ -2829,13 +2869,12 @@ export function registerSessionRoutes( app.get('/api/history/sessions', async (req) => { const query = req.query as { projectKey?: string; offset?: string; limit?: string }; const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - // 128KB (was 16KB): a session restarted many times over the course of a long - // conversation accumulates small bookkeeping lines (mode/permission-mode/ - // last-prompt/queue-operation, one batch per restart) ahead of the real first - // message. 16KB was enough margin for a handful of restarts but not dozens — - // a genuinely tiny first message still came up blank because the metadata - // alone crossed the window. 128KB matches the existing precedent elsewhere in - // this file (line ~1431) rather than inventing a new size. + // scanProjectDir tries smallHeadBuf (16KB, the original size) first for every + // file and only escalates to headBuf (128KB) when that wasn't enough — see the + // comment at the escalation site in scanProjectDir for why unconditional 128KB + // reads were too expensive to keep. 128KB matches the existing precedent + // elsewhere in this file (line ~1431). + const smallHeadBuf = Buffer.alloc(16384); const headBuf = Buffer.alloc(131072); // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin // must only see history whose decoded workingDir is inside their own case space. @@ -2854,7 +2893,7 @@ export function registerSessionRoutes( const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0); const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20)); const projPath = join(projectsDir, query.projectKey); - let all = await scanProjectDir(projPath, query.projectKey, headBuf); + let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf); // Confine to the caller's workspace (a projectKey maps to a single foreign cwd). if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir)); all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); @@ -2867,7 +2906,7 @@ export function registerSessionRoutes( const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); - const list = await scanProjectDir(projPath, projDir, headBuf); + const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf); results.push(...list); } } catch { @@ -2943,12 +2982,13 @@ export function registerSessionRoutes( const history: HistoryInput[] = []; try { const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - // 128KB (was 16KB) — see the sibling allocation above for why. + // See the sibling allocation above for why there are two sizes. + const smallHeadBuf = Buffer.alloc(16384); const headBuf = Buffer.alloc(131072); const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); - const list = await scanProjectDir(projPath, projDir, headBuf); + const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf); for (const h of list) { history.push({ sessionId: h.sessionId, diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 4d85cf86..b9ca43a0 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1396,44 +1396,105 @@ describe('session-routes', () => { expect(ids).not.toContain(sdkId); }); - it('attributes entrypoint from the true first message, not a later one or a bookkeeping line', async () => { - // A transcript that started under an older Claude Code version (no - // entrypoint field) and got resumed under a newer one mid-conversation - // could otherwise pick up entrypoint from a later message, misattributing - // the session's origin. Scanning must anchor on "type":"user"/"assistant" - // lines specifically, not any line that happens to mention "entrypoint". + it('ignores a bookkeeping line that happens to mention "entrypoint" outside a real message record', async () => { + // Scanning must anchor on "type":"user"/"assistant" lines specifically, + // not any line that happens to contain the substring "entrypoint". const home = process.env.HOME as string; - const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-scoping-test'); + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-bookkeeping-test'); await mkdir(projPath, { recursive: true }); const sessionId = '77777777-7777-7777-7777-777777777777'; - // True first message: no entrypoint field (old-version transcript). - const firstLine = - JSON.stringify({ type: 'user', message: { role: 'user', content: 'the genuine first message' } }) + '\n'; - // A bookkeeping line that (hypothetically) mentions entrypoint outside a - // real message record — must not be mistaken for message metadata. const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n'; - // A later message, after the resume, that DOES carry entrypoint: 'cli' — - // this is what the (fixed) scan should find, since it's the first - // user/assistant line that actually carries the field. - const laterLine = - JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a later message' } }) + + const realLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) + '\n'; // scanProjectDir skips files under 4000 bytes. - const body = firstLine + bookkeepingLine + laterLine; + const body = bookkeepingLine + realLine; await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); const res = await harness.app.inject({ method: 'GET', - url: '/api/history/sessions?projectKey=proj-entrypoint-scoping-test', + url: '/api/history/sessions?projectKey=proj-entrypoint-bookkeeping-test', }); expect(res.statusCode).toBe(200); const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); - // entrypoint: 'cli' (from the later message) — shown, not excluded. expect(ids).toContain(sessionId); }); + it('shows a session with ANY interactive (cli) message, even if an earlier message was automated', async () => { + // "First field wins" would have misattributed this: an old transcript + // whose true first message predates the entrypoint field, later resumed + // under something automated (entrypoint: 'sdk-py' on message 2), then + // continued interactively by a real person (entrypoint: 'cli' on message + // 3). Stopping at the first entrypoint-bearing line found ('sdk-py') + // would wrongly exclude a session a human genuinely used. One real + // interactive message anywhere is enough to keep it visible. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-any-cli-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '99999999-9999-9999-9999-999999999999'; + const firstLine = + JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-entrypoint-field message' } }) + '\n'; + const automatedLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'an automated follow-up' }, + }) + '\n'; + const interactiveLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'a real person continued this' }, + }) + '\n'; + + const body = firstLine + automatedLine + interactiveLine; + await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-any-cli-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).toContain(sessionId); + }); + + it('still excludes a session where every entrypoint-bearing message is automated', async () => { + // Mirror of the previous test with no 'cli' message anywhere — proves the + // "any cli wins" fix isn't just failing open unconditionally. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-all-automated-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const firstLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'Review this change for security vulnerabilities.' }, + }) + '\n'; + const secondLine = + JSON.stringify({ + type: 'assistant', + entrypoint: 'sdk-py', + message: { role: 'assistant', content: [{ type: 'text', text: 'Looking at the diff...' }] }, + }) + '\n'; + + const body = firstLine + secondLine; + await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-all-automated-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).not.toContain(sessionId); + }); + it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => { // A session restarted many times over a long conversation accumulates a batch // of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation) diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 2d6e1db8..539af732 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -303,6 +303,42 @@ describe('mergeUnifiedSessions', () => { expect(old!.firstPrompt).toBeUndefined(); }); + it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => { + // The exact scenario COD-140's own comment lists first: a live/persisted row + // whose claudeSessionId aliases to an on-disk transcript. Once that alias + // successfully folds the transcript's own (failed) extraction into this row + // (sources includes 'history'), it must NOT then fall through to the + // workingDir guess and borrow an unrelated sibling's prompt -- same bug as + // the plain history-only case above, but for the resumed-session path the + // backfill mechanism was actually built for. + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }], + history: [ + // The resumed session's OWN transcript -- aliased in via claudeSessionId, + // but its own extraction found nothing. + { + sessionId: 'resumed-uuid', + workingDir: '/shared', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: undefined, + }, + // An unrelated, newer sibling in the same directory. + { + sessionId: 'sibling-uuid', + workingDir: '/shared', + sizeBytes: 6000, + lastModified: '2026-06-01T00:00:00.000Z', + firstPrompt: "unrelated sibling's prompt", + }, + ], + }); + const resumed = merged.find((m) => m.sessionId === 'codeman-resumed'); + expect(resumed).toBeDefined(); + expect([...resumed!.sources].sort()).toEqual(['history', 'live']); + expect(resumed!.firstPrompt).toBeUndefined(); + }); + // COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the // most-recent user prompt also reaches live rows whose id ≠ transcript UUID. it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {