Skip to content

fix(hooks): isolate session-end.js filename using transcript_path UUID (#1494)#1495

Open
ratorin wants to merge 4 commits intoaffaan-m:mainfrom
ratorin:fix/session-end-transcript-path-isolation
Open

fix(hooks): isolate session-end.js filename using transcript_path UUID (#1494)#1495
ratorin wants to merge 4 commits intoaffaan-m:mainfrom
ratorin:fix/session-end-transcript-path-isolation

Conversation

@ratorin
Copy link
Copy Markdown

@ratorin ratorin commented Apr 19, 2026

Summary

Fixes #1494*-session.tmp corruption when another Stop hook spawns a claude subprocess.

In my own setup, a custom AI-summarization Stop hook spawned claude -p ... to generate a session summary. That subprocess also fires the full Stop hook chain, so scripts/hooks/session-end.js is invoked a second time. Because getSessionIdShort() falls back to the project/worktree name when CLAUDE_SESSION_ID is unset, both invocations produced the same {date}-{shortId}-session.tmp path. The subprocess transcript only contained the summarization prompt, so its ### Tasks ended up holding the prompt text and Total user messages: 1, overwriting the parent's valid summary.

Type

  • Hook

Fix

When stdin JSON (or CLAUDE_TRANSCRIPT_PATH) provides a transcript_path, derive shortId from the first 8 hex characters of the session UUID in the filename instead of always calling getSessionIdShort(). The existing fallback is preserved for cases without transcript_path, so nothing changes for other callers.

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();

Parent and subprocess invocations now resolve to different UUIDs, so they write to different files and do not collide.

Testing

New regression test in tests/hooks/hooks.test.js (derives shortId from transcript_path UUID when available) runs session-end.js with stdin carrying a fake transcript path /tmp/.../abcdef12-3456-4789-a012-bcdef3456789.jsonl, without CLAUDE_SESSION_ID set, and asserts the resulting .tmp file name contains abcdef12 (the UUID prefix) rather than the project-name fallback.

Full suite: node tests/hooks/hooks.test.js220 passed, 0 failed on Windows 11 + Node 20.

Notes / request for feedback

  • This is my first contribution to this repo. I ran into the corruption while using a custom Stop hook in my own tooling and fixed it on my side, but the root cause is general enough that anyone spawning claude from another Stop hook would hit it. Happy to adjust the scope or style if you prefer a different direction.
  • Side effect: the number of .tmp files under ~/.claude/sessions/ will grow faster because each real session now gets its own UUID-based file instead of reusing the project-name filename. Related open PRs fix(hooks): add ECC_SESSION_STRICT_MATCH to prevent cross-project session fallback #1481 / fix(hooks): scope session-start injection by shortId to prevent cross-cwd leakage #1485 / Scope SessionStart context injection #1493 are already tightening the read-side matching, so this should compose well with them, but if you want me to also touch up a retention or mtime-ordering pass on the read side in the same PR, let me know.
  • Tested on Windows. The change is pure string parsing on path.basename(...) so macOS/Linux behavior should be identical, but I have not tested on those platforms.

If anything looks off, please comment and I will revise.


Summary by cubic

Prevents session summary .tmp collisions when a Stop hook spawns a claude subprocess by deriving the filename shortId from the transcript_path UUID when available. Parent and subprocess now write to different files while keeping names compatible with getSessionIdShort().

  • Bug Fixes
    • Derive shortId from the last 8 hex chars of the transcript UUID (from stdin or CLAUDE_TRANSCRIPT_PATH), lowercase it, and route through sanitizeSessionId() to match getSessionIdShort() and avoid collisions.
    • When stdin omits transcript_path or provides an empty/non-string value (or malformed JSON), fall back to CLAUDE_TRANSCRIPT_PATH; if still absent, fall back to getSessionIdShort().
    • Add regression tests for UUID-derived shortId, lowercase normalization, and parity when CLAUDE_SESSION_ID equals the transcript UUID.

Written for commit 0c3fc70. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Session file naming now prefers the UUID from the session transcript (using the final 8 hex characters, normalized to lowercase), with the previous fallback preserved — reducing cross-session file conflicts.
  • Tests

    • Added regression tests validating transcript-driven session naming, lowercase normalization, and behavior when an existing session ID matches the transcript 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 affaan-m#1494
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 90b55d2e-5ce3-454c-97cd-014e09bded9d

📥 Commits

Reviewing files that changed from the base of the PR and between 01d8167 and 0c3fc70.

📒 Files selected for processing (1)
  • scripts/hooks/session-end.js
✅ Files skipped from review due to trivial changes (1)
  • scripts/hooks/session-end.js

📝 Walkthrough

Walkthrough

Derives the per-session shortId in scripts/hooks/session-end.js from the UUID embedded in the transcript_path .jsonl filename (last 8 hex chars, case-normalized), falling back to getSessionIdShort() when unavailable. Adds three regression tests validating transcript-derived shortId behavior and lowercase normalization.

Changes

Cohort / File(s) Summary
Session-end UUID extraction
scripts/hooks/session-end.js
Attempt to parse stdin JSON for transcript_path; if present, extract the UUID basename and use the last 8 hex chars (normalized via sanitizeSessionId) as shortId. On missing/invalid transcript_path, fall back to getSessionIdShort(). Session temp filename uses the selected shortId.
Regression tests for shortId behavior
tests/hooks/hooks.test.js
Added three tests that create deterministic UUID-named .jsonl transcripts and run session-end.js with stdin pointing to them; assertions cover transcript-derived shortId, lowercase normalization, and fallback behavior when CLAUDE_SESSION_ID matches the UUID.

Sequence Diagram(s)

(omitted — change is localized to filename selection logic and tests; no multi-component flow requiring a sequence diagram)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I found a UUID tucked in a trail,
eight tiny hops to tell sessions apart,
no spawned child can mix up the tale,
each summary keeps its own little heart,
nibbling bugs away with a happy start.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: using transcript_path UUID to isolate session-end.js filenames and fix issue #1494, which is the primary objective of the PR.
Linked Issues check ✅ Passed The PR fully implements the proposed fix (A) from #1494 by deriving shortId from transcript_path UUID when available, extracting last 8 hex chars, normalizing to lowercase, and falling back to getSessionIdShort() when transcript_path is absent, with comprehensive regression tests covering UUID extraction, lowercase normalization, and backward compatibility.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to fixing #1494: modifying session-end.js to use transcript_path UUID for filename derivation, adding sanitizeSessionId import, and adding three regression tests validating the new behavior with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/hooks/hooks.test.js`:
- Around line 648-654: The test relies on CLAUDE_SESSION_ID being unset but
runScript merges process.env, so explicitly set CLAUDE_SESSION_ID to an empty
string in the env passed to runScript to guarantee the fallback path is
exercised; update the call that invokes runScript(path.join(scriptsDir,
'session-end.js'), stdinJson, { HOME: isoHome, USERPROFILE: isoHome, ... }) to
include CLAUDE_SESSION_ID: '' so the session-end.js behavior and project-name
fallback collision are deterministically tested.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e52d959f-9d1c-4c3b-94b5-ce43a09803f1

📥 Commits

Reviewing files that changed from the base of the PR and between 1a50145 and a35b2d1.

📒 Files selected for processing (2)
  • scripts/hooks/session-end.js
  • tests/hooks/hooks.test.js

Comment thread tests/hooks/hooks.test.js
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 19, 2026

Greptile Summary

This PR addresses a .tmp file collision in session-end.js where a Stop hook that spawns a claude -p ... subprocess causes both the parent and subprocess to write to the same session file, corrupting the parent's summary. The fix derives shortId from the last 8 hex chars of the transcript UUID (lowercased, run through sanitizeSessionId) when transcript_path is available, falling back to getSessionIdShort() otherwise. Prior P1 review concerns (missing .toLowerCase() and missing sanitizeSessionId call) are now resolved in this revision.

Confidence Score: 4/5

Safe to merge for the targeted scenario; residual concern from prior review thread about CLAUDE_SESSION_ID priority vs transcript UUID when they differ is unresolved but unlikely to surface in practice.

The two concrete P1 issues from prior review threads (missing .toLowerCase() and missing sanitizeSessionId call) are now fixed. The CLAUDE_SESSION_ID priority concern (when set alongside a differing transcript UUID) is still not explicitly guarded in code and no test covers that divergence scenario — hence 4 rather than 5.

scripts/hooks/session-end.js — the CLAUDE_SESSION_ID vs transcript-UUID priority ordering (lines 211–220) is the unresolved concern from prior review threads.

Important Files Changed

Filename Overview
scripts/hooks/session-end.js Adds transcript-UUID-based shortId derivation (last 8 hex chars via .slice(-8), lowercased, run through sanitizeSessionId) to prevent .tmp file collisions; falls back to getSessionIdShort(). Prior P1 concerns (missing .toLowerCase() and missing sanitizeSessionId) are now addressed. The CLAUDE_SESSION_ID priority concern from prior review threads remains unresolved but is unlikely to affect real-world usage.
tests/hooks/hooks.test.js Adds three regression tests for #1494: UUID-derived shortId, uppercase normalization to lowercase, and parity when CLAUDE_SESSION_ID equals transcript UUID. No test covers the divergence case (CLAUDE_SESSION_ID set to a different value than the transcript UUID) — the gap flagged in prior review threads.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[session-end.js main] --> B{Parse stdin JSON}
    B -->|valid transcript_path| C[transcriptPath = stdin value]
    B -->|missing or malformed| D{Check CLAUDE_TRANSCRIPT_PATH env}
    D -->|set and non-empty| E[transcriptPath = env value]
    D -->|unset or empty| F[transcriptPath = null]
    C --> G{transcriptPath set?}
    E --> G
    F --> G
    G -->|yes| H{Filename matches UUID regex?}
    H -->|yes| I[shortId = sanitizeSessionId last 8 hex chars lowercased]
    H -->|no| J[shortId = null]
    I --> K{shortId truthy?}
    J --> K
    G -->|no| K
    K -->|no| L[shortId = getSessionIdShort]
    K -->|yes| M[sessionFile = today-shortId-session.tmp]
    L --> M
Loading

Reviews (4): Last reviewed commit: "review: broaden CLAUDE_TRANSCRIPT_PATH f..." | Re-trigger Greptile

Comment thread scripts/hooks/session-end.js
Comment thread scripts/hooks/session-end.js Outdated
Comment thread tests/hooks/hooks.test.js Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/hooks/session-end.js">

<violation number="1" location="scripts/hooks/session-end.js:198">
P1: This unconditionally prefers `transcriptPath` over `CLAUDE_SESSION_ID` for deriving `shortId`. In a normal session where both are present, `getSessionIdShort()` would use the last 8 chars of `CLAUDE_SESSION_ID` — but this new code path bypasses that, silently changing the filename and orphaning existing `.tmp` files written under the old name. Guard this with `!process.env.CLAUDE_SESSION_ID` so the transcript-UUID derivation only activates in the fallback case the fix targets.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread scripts/hooks/session-end.js
- 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 affaan-m#1494
@ratorin
Copy link
Copy Markdown
Author

ratorin commented Apr 19, 2026

Thanks for the reviews! Pushed 93cd5f4 to address the feedback:

P1 (cubic, greptile): The concern about unconditional override and orphaned files is valid. Rather than guarding with !CLAUDE_SESSION_ID (which would bring back the collision for subprocesses that inherit CLAUDE_SESSION_ID from their parent), I aligned the UUID extraction with getSessionIdShort()'s existing .slice(-8) convention. The new code extracts the last 8 chars of the transcript UUID, so for any single session, the filename is identical whether it comes from CLAUDE_SESSION_ID or from transcript_path. Existing .tmp files are not orphaned on upgrade, and sibling subprocesses (which have a different transcript UUID) still resolve to different filenames.

P2 (greptile): Added .toLowerCase() on the extracted hex prefix so uppercase UUIDs normalize the same way sanitizeSessionId() does.

P2 (coderabbitai): Added CLAUDE_SESSION_ID: '' explicitly in the test env to defeat parent-process leakage.

P2 (greptile — missing coexistence test): Added two new regression tests:

  • normalizes transcript UUID shortId to lowercase
  • matches getSessionIdShort when transcript UUID equals CLAUDE_SESSION_ID — explicitly guards the backward-compat guarantee.

Full suite: 222/222 passed locally on Windows 11 + Node 20.

Please take another look, and let me know if there's anything else to tweak. First time contributing here, so happy to follow your style/scope guidance.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/hooks/hooks.test.js`:
- Around line 654-655: The inline comment is inverted: clearing the
CLAUDE_SESSION_ID env var prevents parent env leakage and causes the test to
exercise the transcript_path-derived naming (not the getSessionIdShort()
fallback). Update the comment near the CLAUDE_SESSION_ID clear in
tests/hooks/hooks.test.js to state that removing CLAUDE_SESSION_ID forces the
transcript_path branch, and mention getSessionIdShort() only as the
alternative/fallback branch that is not exercised here.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88b96857-4ea3-40e8-8465-1a04c832a220

📥 Commits

Reviewing files that changed from the base of the PR and between a35b2d1 and 93cd5f4.

📒 Files selected for processing (2)
  • scripts/hooks/session-end.js
  • tests/hooks/hooks.test.js
✅ Files skipped from review due to trivial changes (1)
  • scripts/hooks/session-end.js

Comment thread tests/hooks/hooks.test.js Outdated
Comment thread scripts/hooks/session-end.js Outdated
- 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 affaan-m#1494
@ratorin
Copy link
Copy Markdown
Author

ratorin commented Apr 19, 2026

Pushed 01d8167:

  • greptile P1: Route transcript UUID shortId through sanitizeSessionId() to stay byte-for-byte compatible with getSessionIdShort() for any non-standard IDs.
  • coderabbit P2: Reworded the inverted inline comment to correctly describe the transcript_path branch as the one being exercised.

Tests still green: 222/222.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/hooks/session-end.js (1)

184-190: ⚠️ Potential issue | 🟠 Major

Restore env fallback when parsed stdin lacks a usable transcript path.

Right now CLAUDE_TRANSCRIPT_PATH is only consulted when JSON.parse(stdinData) throws. If stdin is valid JSON but omits transcript_path or provides an empty/non-string value, transcriptPath stays unusable and Line 212 falls back to the project-name-based getSessionIdShort(), which can reintroduce the collision this PR is fixing for the env compatibility path.

🐛 Proposed fix
   let transcriptPath = null;
   try {
     const input = JSON.parse(stdinData);
-    transcriptPath = input.transcript_path;
+    if (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;
+    // Ignore malformed stdin; try env var for backwards compatibility below.
+  }
+
+  const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
+  if (!transcriptPath && typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) {
+    transcriptPath = envTranscriptPath;
   }

As per coding guidelines, “All user inputs must be validated” and “Always validate all user input before processing at system boundaries.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/session-end.js` around lines 184 - 190, Parsed stdin may be
valid JSON but still lack a usable transcript_path, so after
JSON.parse(stdinData) check that input.transcript_path is a non-empty string
(e.g. typeof === 'string' and trimmed length > 0) before assigning to
transcriptPath; if it is missing/empty/falsy then fall back to
process.env.CLAUDE_TRANSCRIPT_PATH instead of leaving transcriptPath unusable
(this change affects the try block where stdinData is parsed and the variables
transcriptPath and input are set, and preserves the existing catch that also
falls back to process.env.CLAUDE_TRANSCRIPT_PATH), ensuring downstream
getSessionIdShort() fallback only runs when both sources are absent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@scripts/hooks/session-end.js`:
- Around line 184-190: Parsed stdin may be valid JSON but still lack a usable
transcript_path, so after JSON.parse(stdinData) check that input.transcript_path
is a non-empty string (e.g. typeof === 'string' and trimmed length > 0) before
assigning to transcriptPath; if it is missing/empty/falsy then fall back to
process.env.CLAUDE_TRANSCRIPT_PATH instead of leaving transcriptPath unusable
(this change affects the try block where stdinData is parsed and the variables
transcriptPath and input are set, and preserves the existing catch that also
falls back to process.env.CLAUDE_TRANSCRIPT_PATH), ensuring downstream
getSessionIdShort() fallback only runs when both sources are absent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b1534e8-089b-4bb0-b449-2837bfe0deef

📥 Commits

Reviewing files that changed from the base of the PR and between 93cd5f4 and 01d8167.

📒 Files selected for processing (2)
  • scripts/hooks/session-end.js
  • tests/hooks/hooks.test.js
✅ Files skipped from review due to trivial changes (1)
  • tests/hooks/hooks.test.js

…y 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 affaan-m#1494
@ratorin
Copy link
Copy Markdown
Author

ratorin commented Apr 19, 2026

Pushed 0c3fc70: broadened the CLAUDE_TRANSCRIPT_PATH env fallback to cover valid-JSON-but-missing/empty/non-string transcript_path (not only malformed JSON). Catches the edge case coderabbit flagged without regressing the malformed-JSON branch. Tests still green: 222/222.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

session-end.js: *-session.tmp corruption from subprocess spawn in Stop hook chain

1 participant