mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(history): entrypoint detection was first-field-wins, plus a two-tier 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.
This commit is contained in:
@@ -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<string | null> {
|
||||
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<HistorySession[]> {
|
||||
async function scanProjectDir(
|
||||
projPath: string,
|
||||
projDir: string,
|
||||
smallHeadBuf: Buffer,
|
||||
headBuf: Buffer
|
||||
): Promise<HistorySession[]> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user