[BUG] VS Code window reload fires SessionStart twice (throwaway source=startup + resume) — creates a phantom session hooks cannot distinguish
Summary
In the VS Code extension, reloading the window (or reopening it) fires the SessionStart hook twice, a few seconds apart:
source=startupwith a brand-new, throwawaysession_id, thensource=resumewith the durablesession_idof the session actually being resumed.
The startup session from step 1 is immediately abandoned — the window resumes the session from step 2, and all subsequent activity (including MCP server subprocesses) runs under the durable id. But any SessionStart hook that records or bootstraps per-session state has already created an entry for the throwaway id. The result is a phantom session per reload, and there is no hook-visible field that distinguishes the throwaway startup from a genuine first-launch startup, so a hook cannot filter it out.
Environment
- Claude Code v2.1.197 (VS Code extension,
CLAUDE_CODE_ENTRYPOINT=claude-vscode), macOS. (Latest at time of filing ~v2.1.207; no related changelog entry seen.)
Reproduction
- Configure a
SessionStarthook (matchersstartupandresume) that logs the stdin payloadsource+session_idand theCLAUDE_CODE_SESSION_IDenv var. - Open a Claude Code chat in VS Code and use it briefly (so there's a session to resume).
- Reload Window (Cmd/Ctrl+Shift+P → "Reload Window").
- Inspect the hook log.
Actual behavior
The reload produces two hook fires (~3–5s apart; observed gaps ranged 0.3s–5.5s):
source=startup session_id=<AAAA — new, never seen before> env CLAUDE_CODE_SESSION_ID=<AAAA>
source=resume session_id=<BBBB — the durable, pre-reload id> env CLAUDE_CODE_SESSION_ID=<BBBB>
The <AAAA> session is never used again. A genuine fresh launch (new chat, nothing to resume) fires a single startup with its own id and no following resume.
The throwaway startup is indistinguishable from a genuine startup
Capturing the full SessionStart payload and the CLAUDE_*/VSCODE_* env for all three cases — throwaway startup, resume, and a genuine fresh-launch startup — the throwaway startup and the genuine startup are byte-for-byte identical in every field except the per-session session_id / transcript_path:
| Field | throwaway startup | genuine startup | resume |
| --- | --- | --- | --- |
| source | startup | startup | resume |
| payload session_title | absent | absent | present |
| env CLAUDE_CODE_CHILD_SESSION | 1 | 1 | 1 |
| env CLAUDE_CODE_ENTRYPOINT | claude-vscode | claude-vscode | claude-vscode |
| all other payload/env fields | identical | identical | — |
So CLAUDE_CODE_CHILD_SESSION=1 is present on all three (it appears to mark "running under the VS Code extension host," not a throwaway), and session_title only distinguishes resume from startup — neither separates a throwaway startup from a genuine one. env CLAUDE_CODE_SESSION_ID always equals the per-event payload session_id.
Impact
Any SessionStart-hook-based session tracking (analytics, per-session bootstrapping, audit/session records) accumulates one phantom session per window reload, with no reliable way to filter it — the hook has no signal at startup time that the session will be superseded by a resume.
Expected / requested
Either:
- Don't fire a
source=startupfor the throwaway session on reload — fire onlysource=resumefor the session being restored (matching what actually happens); or - If the transient
startupis intentional, expose a field (payload or env) that flags it as superseded/child so a hook can no-op on it — e.g. awill_resume/supersededflag on the startup payload, or a distinct env marker that is genuinely absent on a real first launch.
Happy to provide the full captured payloads/env for all three cases.