Skip to content

Windows: SDK_SPAWN leaks visible cmd.exe windows — detached:true + windowsHide:true is broken in Node on Windows #2323

Description

@xiaohao0619

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.jsbun.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

  1. Install claude-mem@thedotmack on Windows.
  2. Open Claude Code on any project that exercises Read and writes (so PreToolUse, PostToolUse, and Stop hooks all fire).
  3. Work for ~20 min — note cmd.exe windows flashing open on each tool call.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions