From a35b2d125d6e2177298748167d424f0a441a06ee Mon Sep 17 00:00:00 2001 From: Taro Kawakami Date: Sun, 19 Apr 2026 11:37:32 +0900 Subject: [PATCH 1/4] fix(hooks): isolate session-end.js filename using transcript_path UUID When session-end.js runs and CLAUDE_SESSION_ID is unset, getSessionIdShort() falls back to the project/worktree name. If any other Stop-hook in the chain spawns a claude subprocess (e.g. an AI-summary generator using 'claude -p'), the subprocess also fires the full Stop chain and writes to the same project- name-based filename, clobbering the parent's valid session summary with a summary of the summarization prompt itself. Fix: when stdin JSON (or CLAUDE_TRANSCRIPT_PATH) provides a transcript_path, extract the first 8 hex chars of the session UUID from the filename and use that as shortId. Falls back to the original getSessionIdShort() when no transcript_path is available, so existing behavior is preserved for all callers that do not set it. Adds a regression test in tests/hooks/hooks.test.js. Refs #1494 --- scripts/hooks/session-end.js | 11 ++++++++++- tests/hooks/hooks.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index af378001..3c1ec0ac 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -190,7 +190,16 @@ async function main() { const sessionsDir = getSessionsDir(); const today = getDateString(); - const shortId = getSessionIdShort(); + // Prefer the real session UUID (first 8 chars) from transcript_path when available. + // Without this, a parent session and any `claude -p ...` subprocess spawned by + // another Stop-hook share the project-name fallback filename, and the subprocess + // overwrites the parent's summary. See issue #1494 for full repro details. + let shortId = null; + if (transcriptPath) { + const m = path.basename(transcriptPath).match(/([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i); + if (m) { shortId = m[1]; } + } + if (!shortId) { shortId = getSessionIdShort(); } const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); const sessionMetadata = getSessionMetadata(); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 78664fa6..2c876404 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -633,6 +633,40 @@ async function runTests() { passed++; else failed++; + // Regression test for #1494: transcript_path UUID takes precedence over fallback + if ( + await asyncTest('derives shortId from transcript_path UUID when available', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-${Date.now()}`); + const transcriptUuid = 'abcdef12-3456-4789-a012-bcdef3456789'; + const expectedShortId = 'abcdef12'; // First 8 chars of UUID + const transcriptPath = path.join(isoHome, 'transcripts', `${transcriptUuid}.jsonl`); + + try { + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, ''); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome, + // CLAUDE_SESSION_ID intentionally unset so that without the fix the project-name + // fallback would be used, exposing the filename collision described in #1494. + }); + + const sessionsDir = getCanonicalSessionsDir(isoHome); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); + + assert.ok(fs.existsSync(sessionFile), `Session file with transcript UUID shortId should exist: ${sessionFile}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( await asyncTest('writes project, branch, and worktree metadata into new session files', async () => { const isoHome = path.join(os.tmpdir(), `ecc-session-metadata-${Date.now()}`); From 93cd5f4cff5aed43838c4d3c862f67418d675ab2 Mon Sep 17 00:00:00 2001 From: Taro Kawakami Date: Sun, 19 Apr 2026 14:19:29 +0900 Subject: [PATCH 2/4] review: address P1/P2 bot feedback on shortId derivation - Use last-8 chars of transcript UUID instead of first-8, matching getSessionIdShort()'s .slice(-8) convention. Same session now produces the same filename whether shortId comes from CLAUDE_SESSION_ID or transcript_path, so existing .tmp files are not orphaned on upgrade. - Normalize extracted hex prefix to lowercase to avoid case-driven filename divergence from sanitizeSessionId()'s lowercase output. - Explicitly clear CLAUDE_SESSION_ID in the first regression test so the env leak from parent test runs cannot hide the fallback path. - Add regression tests for the lowercase-normalization path and for the case where CLAUDE_SESSION_ID and transcript_path refer to the same UUID (backward compat guarantee). Refs #1494 --- scripts/hooks/session-end.js | 13 ++++-- tests/hooks/hooks.test.js | 80 ++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 3c1ec0ac..bfd9a2e8 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -190,14 +190,19 @@ async function main() { const sessionsDir = getSessionsDir(); const today = getDateString(); - // Prefer the real session UUID (first 8 chars) from transcript_path when available. + // Derive shortId from transcript_path UUID when available, using the SAME + // last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps + // backward compatibility for normal sessions (the derived shortId matches what + // getSessionIdShort() would have produced from the same UUID), while making + // every session map to a unique filename based on its own transcript UUID. + // // Without this, a parent session and any `claude -p ...` subprocess spawned by - // another Stop-hook share the project-name fallback filename, and the subprocess + // another Stop hook share the project-name fallback filename, and the subprocess // overwrites the parent's summary. See issue #1494 for full repro details. let shortId = null; if (transcriptPath) { - const m = path.basename(transcriptPath).match(/([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i); - if (m) { shortId = m[1]; } + const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i); + if (m) { shortId = m[1].slice(-8).toLowerCase(); } } if (!shortId) { shortId = getSessionIdShort(); } const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2c876404..0cd981e2 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -633,12 +633,14 @@ async function runTests() { passed++; else failed++; - // Regression test for #1494: transcript_path UUID takes precedence over fallback + // Regression test for #1494: transcript_path UUID-derived shortId (last 8 chars) + // isolates sibling subprocess invocations while preserving getSessionIdShort() + // backward compatibility (same `.slice(-8)` convention). if ( await asyncTest('derives shortId from transcript_path UUID when available', async () => { const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-${Date.now()}`); const transcriptUuid = 'abcdef12-3456-4789-a012-bcdef3456789'; - const expectedShortId = 'abcdef12'; // First 8 chars of UUID + const expectedShortId = 'f3456789'; // Last 8 chars of UUID (matches getSessionIdShort convention) const transcriptPath = path.join(isoHome, 'transcripts', `${transcriptUuid}.jsonl`); try { @@ -649,8 +651,9 @@ async function runTests() { await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: isoHome, USERPROFILE: isoHome, - // CLAUDE_SESSION_ID intentionally unset so that without the fix the project-name - // fallback would be used, exposing the filename collision described in #1494. + // Explicitly clear CLAUDE_SESSION_ID so parent env does not leak in and + // force the getSessionIdShort() fallback instead of the transcript path. + CLAUDE_SESSION_ID: '' }); const sessionsDir = getCanonicalSessionsDir(isoHome); @@ -667,6 +670,75 @@ async function runTests() { passed++; else failed++; + // Regression test for #1494: uppercase UUID hex digits should be normalized to + // lowercase so the filename is consistent with getSessionIdShort()'s output. + if ( + await asyncTest('normalizes transcript UUID shortId to lowercase', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-upper-${Date.now()}`); + const transcriptUuid = 'ABCDEF12-3456-4789-A012-BCDEF3456789'; + const expectedShortId = 'f3456789'; // last 8 lowercased + const transcriptPath = path.join(isoHome, 'transcripts', `${transcriptUuid}.jsonl`); + + try { + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, ''); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome, + CLAUDE_SESSION_ID: '' + }); + + const sessionsDir = getCanonicalSessionsDir(isoHome); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); + + assert.ok(fs.existsSync(sessionFile), `Session file with lowercase shortId should exist: ${sessionFile}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + // Regression test for #1494: when CLAUDE_SESSION_ID and transcript_path refer to the + // same UUID, the derived shortId must be identical to the pre-fix behaviour so that + // existing .tmp files are not orphaned on upgrade. + if ( + await asyncTest('matches getSessionIdShort when transcript UUID equals CLAUDE_SESSION_ID', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-match-${Date.now()}`); + const sessionUuid = '11223344-5566-4778-8899-aabbccddeeff'; + const expectedShortId = 'ccddeeff'; // last 8 chars of both transcript UUID and CLAUDE_SESSION_ID + const transcriptPath = path.join(isoHome, 'transcripts', `${sessionUuid}.jsonl`); + + try { + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, ''); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome, + CLAUDE_SESSION_ID: sessionUuid + }); + + const sessionsDir = getCanonicalSessionsDir(isoHome); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); + + assert.ok(fs.existsSync(sessionFile), `Session filename should match the pre-fix CLAUDE_SESSION_ID-based name: ${sessionFile}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( await asyncTest('writes project, branch, and worktree metadata into new session files', async () => { const isoHome = path.join(os.tmpdir(), `ecc-session-metadata-${Date.now()}`); From 01d816781e983f43f7e4ee330d009ba548c4045f Mon Sep 17 00:00:00 2001 From: Taro Kawakami Date: Sun, 19 Apr 2026 14:30:00 +0900 Subject: [PATCH 3/4] review: apply sanitizeSessionId to UUID shortId, fix test comment - Route the transcript-derived shortId through sanitizeSessionId so the fallback and transcript branches remain byte-for-byte equivalent for any non-UUID session IDs that still land in CLAUDE_SESSION_ID (greptile P1). - Clarify the inline comment in the first regression test: clearing CLAUDE_SESSION_ID exercises the transcript_path branch, not the getSessionIdShort() fallback (coderabbit P2). Refs #1494 --- scripts/hooks/session-end.js | 7 ++++++- tests/hooks/hooks.test.js | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index bfd9a2e8..4ad08a4f 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -16,6 +16,7 @@ const { getDateString, getTimeString, getSessionIdShort, + sanitizeSessionId, getProjectName, ensureDir, readFile, @@ -202,7 +203,11 @@ async function main() { let shortId = null; if (transcriptPath) { const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i); - if (m) { shortId = m[1].slice(-8).toLowerCase(); } + if (m) { + // Run through sanitizeSessionId() for byte-for-byte parity with + // getSessionIdShort(sessionId.slice(-8)). + shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase()); + } } if (!shortId) { shortId = getSessionIdShort(); } const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 0cd981e2..5de6bd52 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -651,8 +651,10 @@ async function runTests() { await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: isoHome, USERPROFILE: isoHome, - // Explicitly clear CLAUDE_SESSION_ID so parent env does not leak in and - // force the getSessionIdShort() fallback instead of the transcript path. + // Clear CLAUDE_SESSION_ID so parent-process env does not leak into the + // child and the test deterministically exercises the transcript_path + // branch (getSessionIdShort() is the alternative path that is not + // exercised here). CLAUDE_SESSION_ID: '' }); From 0c3fc7074e2efed089839e26d6deff6554856a43 Mon Sep 17 00:00:00 2001 From: Taro Kawakami Date: Sun, 19 Apr 2026 14:35:21 +0900 Subject: [PATCH 4/4] review: broaden CLAUDE_TRANSCRIPT_PATH fallback to cover missing/empty JSON fields Previously the env fallback ran only when JSON.parse threw. If stdin was valid JSON but omitted transcript_path or provided a non-string/empty value, the script dropped to the getSessionIdShort() fallback path, re-introducing the collision this PR targets. Validate the parsed transcript_path and apply the env-var fallback for any unusable value, not just malformed JSON. Matches coderabbit's outside-diff suggestion and keeps both input-source paths equivalent. Refs #1494 --- scripts/hooks/session-end.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 4ad08a4f..d7ed8f59 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -179,14 +179,22 @@ function mergeSessionHeader(content, today, currentTime, metadata) { } async function main() { - // Parse stdin JSON to get transcript_path + // Parse stdin JSON to get transcript_path; fall back to env var on missing, + // empty, or non-string values as well as on malformed JSON. let transcriptPath = null; try { const input = JSON.parse(stdinData); - transcriptPath = input.transcript_path; + if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) { + transcriptPath = input.transcript_path; + } } catch { - // Fallback: try env var for backwards compatibility - transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + // Malformed stdin: fall through to the env-var fallback below. + } + if (!transcriptPath) { + const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) { + transcriptPath = envTranscriptPath; + } } const sessionsDir = getSessionsDir();