Title
Windows: SDK_SPAWN leaks visible cmd.exe windows — detached:true + windowsHide:true is broken in Node on Windows
Body
Summary
On Windows, every PostToolUse / Stop / UserPromptSubmit hook that triggers a Claude Agent SDK subcall (summarize / observation / file-context / session-init) opens a visible cmd.exe console window. Most close when the subcall completes, but any subcall that hangs (network stall, prompt is too long retry loop, killed mid-flight, etc.) leaves its console window dangling forever and the user has to close them by hand. After a full work session there are typically 1–3 orphan cmd.exe windows on the desktop.
Environment
- OS: Windows 11 Pro 22H2 (10.0.22621)
- Node: spawning via
bun-runner.js → bun.exe running bundled worker-service.cjs
- Plugin:
claude-mem@thedotmack v12.4.8 (cache path ~/.claude/plugins/cache/thedotmack/claude-mem/12.4.8/)
- Claude Code: stdio plugin host
Root cause
In scripts/worker-service.cjs (the bundled SDK_SPAWN path), Claude CLI sessions are launched with both flags set together:
spawn("cmd.exe", ["/d","/c", e.command, ...s], {
cwd: e.cwd, env: i,
detached: true, // ← creates new process group via DETACHED_PROCESS
stdio: ["pipe","pipe","pipe"],
signal: e.signal,
windowsHide: true // ← silently ignored when detached:true
});
…and the same options on the non-cmd branch right after it.
This is a long-standing Node/libuv behavior on Windows: when detached:true is set, the child is created with the DETACHED_PROCESS creation flag, which conflicts with CREATE_NO_WINDOW (the flag windowsHide:true would otherwise add). Result: windowsHide becomes a no-op and a fresh console window pops up for every spawn.
References:
stdio:["pipe","pipe","pipe"] already keeps the child attached for I/O, so detached:true here doesn't actually buy independence from the parent — the flag was likely set defensively but is what makes the windows show.
Reproduction
- Install
claude-mem@thedotmack on Windows.
- Open Claude Code on any project that exercises
Read and writes (so PreToolUse, PostToolUse, and Stop hooks all fire).
- Work for ~20 min — note
cmd.exe windows flashing open on each tool call.
- End the session — observe 1–3 leftover
cmd.exe windows. (They're orphans of subcalls that hung; the daemon's taskkill /T /F cleanup misses them because they're in their own process group.)
Suggested fix
For the SDK_SPAWN call (the one wrapping claude CLI for summaries/observations), drop detached:true. With stdio:["pipe","pipe","pipe"] the child is kept attached anyway, so dropping detached has no functional cost and lets windowsHide:true actually take effect:
- spawn("cmd.exe", ["/d","/c", e.command, ...s], {
- cwd, env, detached: true, stdio: ["pipe","pipe","pipe"],
- signal, windowsHide: true
- })
+ spawn("cmd.exe", ["/d","/c", e.command, ...s], {
+ cwd, env, detached: false, stdio: ["pipe","pipe","pipe"],
+ signal, windowsHide: true
+ })
(Same change on the sibling non-cmd branch right after it.)
The two daemon-self-spawn sites (worker --daemon lazy-spawn and the Windows --daemon spawn near the end of the bundle) need to keep detached:true because they're true fire-and-forget background processes with stdio:"ignore" — but those have stdio:"ignore" which avoids the visible-window issue in practice; if they ever do leak windows, the alternative pattern is cmd /c start /B /MIN ... to force-hide.
Local workaround verified
Patched worker-service.cjs in place, changing only the two SDK_SPAWN occurrences from detached:!0 to detached:!1, leaving both --daemon self-spawns untouched. Killed the stale daemon (taskkill /F /PID <pid>); the next hook lazy-respawned it with the patched code. After ~30 min of mixed work no new visible cmd.exe windows appeared.
Why this matters
- Visual noise — every tool call flashes a console.
- Orphan window pile-up — leaked windows persist across sessions until reboot.
taskkill /T /F from the worker can't reach them because detached:true puts them in a separate process group.
- Probably also affects Windows users on plans where the SDK gets rate-limited / stalls more often.
Happy to send a PR if useful.
Title
Windows: SDK_SPAWN leaks visible cmd.exe windows —
detached:true+windowsHide:trueis broken in Node on WindowsBody
Summary
On Windows, every
PostToolUse/Stop/UserPromptSubmithook that triggers a Claude Agent SDK subcall (summarize / observation / file-context / session-init) opens a visiblecmd.execonsole window. Most close when the subcall completes, but any subcall that hangs (network stall,prompt is too longretry loop, killed mid-flight, etc.) leaves its console window dangling forever and the user has to close them by hand. After a full work session there are typically 1–3 orphancmd.exewindows on the desktop.Environment
bun-runner.js→bun.exerunning bundledworker-service.cjsclaude-mem@thedotmackv12.4.8 (cache path~/.claude/plugins/cache/thedotmack/claude-mem/12.4.8/)Root cause
In
scripts/worker-service.cjs(the bundled SDK_SPAWN path), Claude CLI sessions are launched with both flags set together:…and the same options on the non-cmd branch right after it.
This is a long-standing Node/libuv behavior on Windows: when
detached:trueis set, the child is created with theDETACHED_PROCESScreation flag, which conflicts withCREATE_NO_WINDOW(the flagwindowsHide:truewould otherwise add). Result:windowsHidebecomes a no-op and a fresh console window pops up for every spawn.References:
DETACHED_PROCESSvsCREATE_NO_WINDOWstdio:["pipe","pipe","pipe"]already keeps the child attached for I/O, sodetached:truehere doesn't actually buy independence from the parent — the flag was likely set defensively but is what makes the windows show.Reproduction
claude-mem@thedotmackon Windows.Readand writes (soPreToolUse,PostToolUse, andStophooks all fire).cmd.exewindows flashing open on each tool call.cmd.exewindows. (They're orphans of subcalls that hung; the daemon'staskkill /T /Fcleanup misses them because they're in their own process group.)Suggested fix
For the SDK_SPAWN call (the one wrapping
claudeCLI for summaries/observations), dropdetached:true. Withstdio:["pipe","pipe","pipe"]the child is kept attached anyway, so dropping detached has no functional cost and letswindowsHide:trueactually take effect:(Same change on the sibling non-cmd branch right after it.)
The two daemon-self-spawn sites (
worker --daemonlazy-spawn and the Windows--daemonspawn near the end of the bundle) need to keepdetached:truebecause they're true fire-and-forget background processes withstdio:"ignore"— but those havestdio:"ignore"which avoids the visible-window issue in practice; if they ever do leak windows, the alternative pattern iscmd /c start /B /MIN ...to force-hide.Local workaround verified
Patched
worker-service.cjsin place, changing only the two SDK_SPAWN occurrences fromdetached:!0todetached:!1, leaving both--daemonself-spawns untouched. Killed the stale daemon (taskkill /F /PID <pid>); the next hook lazy-respawned it with the patched code. After ~30 min of mixed work no new visiblecmd.exewindows appeared.Why this matters
taskkill /T /Ffrom the worker can't reach them becausedetached:trueputs them in a separate process group.Happy to send a PR if useful.