[BUG] --resume in REPL mode fires duplicate SessionStart hook with wrong session_id (spurious startup event)
Bug Description
When using claude --resume <session-id> in interactive REPL mode, two SessionStart hook events are fired instead of one. One has the correct session_id and source: "resume", but the other has a new random UUID and source: "startup", with a transcript_path pointing to a nonexistent/empty file.
This breaks integrations that use hooks to track sessions by session_id.
Reproduction Steps
- Create a SessionStart hook that logs the full JSON input to a file:
#!/bin/bash
INPUT=$(cat)
echo "$INPUT" | python3 -m json.tool >> /tmp/session-start-debug.log
- Start a session, send a message, then exit
- Resume with:
claude --resume <session-id> - Send a message
- Check the log file
Observed Behavior
Two SessionStart events arrive (same timestamp):
{
"session_id": "431cad5d-8eed-4182-9ed9-2c9fddd569e5",
"transcript_path": "...431cad5d-8eed-4182-9ed9-2c9fddd569e5.jsonl",
"hook_event_name": "SessionStart",
"source": "resume"
}
{
"session_id": "42345f1a-34b2-4808-9f8b-9908b5a52d0d",
"transcript_path": "...42345f1a-34b2-4808-9f8b-9908b5a52d0d.jsonl",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-opus-4-6"
}
- The
startupevent has a random new UUID (the process's internal session_id) and itstranscript_pathpoints to an empty/nonexistent file. - The arrival order is non-deterministic — sometimes
startuparrives first, sometimesresume. - All subsequent hooks (UserPromptSubmit, Stop, etc.) correctly use the original session_id.
- The transcript file is also correctly written to the original session's file.
Expected Behavior
Only one SessionStart event should fire with source: "resume" and the original session_id. The spurious startup event should not be emitted during --resume.
Root Cause (from source analysis)
In cli.js, the lj("startup") call (which fires SessionStart with source: "startup") is guarded by:
let gq = p || B || r || _1 ? null : lj("startup", { agentType: ..., model: ... });
Where p = initOnly, B = init, r = maintenance, _1 = !isInteractive.
The --resume and --continue flags are not included in this guard condition, so lj("startup") fires as a background promise even during resume. Meanwhile, lj("resume") is correctly called inside the is() function with the proper session_id.
The fix would be adding the resume/continue flags to the guard:
let gq = p || B || r || _1 || H.resume || H.continue ? null : lj("startup", { agentType: ..., model: ... });
Impact
- Integrations that capture
session_idfrom the first SessionStart event may capture the wrong (ephemeral) ID - The spurious
transcript_pathpoints to an empty file, breaking transcript watchers - Non-deterministic arrival order makes it impossible to reliably distinguish the correct event without inspecting the
sourcefield
Note
This does not affect --print mode — claude -p --resume <id> correctly fires only one SessionStart with the original session_id.
Environment
- Claude Code version: 2.1.34
- macOS (arm64)
- Related issues: #8069, #12235, #10806 (these track a different but related problem — session_id mismatch — which may now be partially caused by this duplicate event)
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗