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) ==========