Windows: VS Code extension spawns 14 helper processes without windowsHide, each opening a focus-stealing console window
<!-- SUGGESTED TITLE:
Windows: VS Code extension spawns 14 helper processes without windowsHide, each opening a focus-stealing console window
-->
Summary
On Windows, the Claude Code VS Code extension repeatedly opens console windows that take the foreground and eat keystrokes. It reads as random flashing during normal editing — never at a moment the user can associate with an action.
The cause is structural, not incidental. The VS Code extension host is a GUI process with no attached console (GetConsoleWindow() == 0). When it spawns a console-subsystem executable (rg.exe, git.exe, where.exe, claude.exe, wsl.exe) without windowsHide: true, Windows has nothing for the child to inherit and allocates a brand-new visible console. On Windows 11, whose default console host is Windows Terminal, that renders as a real Terminal window that takes the foreground.
The same calls under the terminal CLI inherit the terminal's existing console and are invisible. This is structurally a VS Code extension defect, and CLI behaviour is not evidence that a call site is safe.
I count 14 Windows-reachable child_process call sites in the shipped extension bundle that pass no windowsHide. The main session spawn is correctly hidden, which is exactly why the symptom does not look like "Claude started something" — it is all helper code.
Environment
| | |
|---|---|
| Extension | anthropic.claude-code 2.1.241 (win32-x64) |
| VS Code | 1.134.0 (x64) |
| OS | Windows 11 Pro, build 10.0.26200 |
| Default terminal application | Windows Terminal (Windows 11 default) |
| Mode | Native panel (not claudeCode.useTerminal) |
The mechanism, measured
Runtime probe, measured 2026-08-23 on Windows 11 Pro 26200. A parent process was created with DETACHED_PROCESS so that GetConsoleWindow() == 0, reproducing the extension host's state, and spawned a console-subsystem child twice:
NO windowsHide (flags = 0) childPid=66360 hwnd=0x3610D4 visible=True
windowsHide = true (CREATE_NO_WINDOW) childPid=78716 hwnd=0x0 visible=False
Node's spawn / exec / execFile default windowsHide to false. libuv maps true to CREATE_NO_WINDOW + STARTF_USESHOWWINDOW / SW_HIDE. There is no ambiguity here: with the flag the window does not exist, without it, it does.
The Windows Terminal multiplier
Also measured 2026-08-23, same machine, same probe, only the default terminal application changed:
| Default terminal application | Window lifetime | Took foreground |
|---|---|---|
| Windows Terminal (Windows 11 default) | 2340 ms | 8 / 8 samples |
| Legacy Console Host (conhost.exe) | 30 ms | 0 / 3 samples |
This matters for triage. The defect is usually described as a "brief flash", which is what it is under the legacy console host. On a default Windows 11 install it is a two-second, foreground-stealing window per spawn. Keystrokes typed into it are lost, and with several spawns in flight it makes the editor unusable rather than merely annoying. #76423 raised the photosensitivity angle on the CLI side; the same applies here.
Call sites
Static analysis of the shipped, minified extension.js from the 2.1.241 win32-x64 VSIX (2,947,529 bytes as shipped) — read from the bundle, not verified at runtime by symbol. Method: every <alias>.spawn|spawnSync|exec|execFile|execFileSync|execSync|fork( where <alias> resolves to a child_process require, then checked for a windowsHide key in the options object. Offsets are character offsets into the UTF-8-decoded bundle and are a pointer to the source location, not a patch target. Symbol names are post-minification.
Windows-reachable, missing windowsHide
| ~char | call | when it fires |
|---|---|---|
| 2806166 | SM.execFile(rg, args, {cwd, maxBuffer:2e7, timeout:1e4}) | @-mention / fuzzy file search — once per invocation, while the user types |
| 2577367 | eRe.spawn(e, t, {cwd, shell:!1}) — the up() helper | prompt-suggestion recon: 6–7 git processes per run, five of them concurrent (config user.email, status --porcelain, rev-parse, log, diff --stat, diff HEAD) |
| 2731498 | uM.execFile("git", ["check-ignore", fsPath], {cwd}) | per referenced file (memoised per fsPath) |
| 2601333 | jL.spawn(claude, ["auth","status","--json"], {cwd, env, shell:!1}) | activation, and again on every config-epoch change |
| 2628517 | jL.spawn(e, t, {cwd, shell:!1}) — the execCommand RPC | generic exec requested from the webview |
| 2600531 | jL.spawn(claude, ["auth","logout"], {env, shell:!1}) | logout |
| 2582444 | ZRe.execFile("git", ["remote","get-url","origin"], {cwd}) | teleport repository detection (cached per instance) |
| 2711734 | bNe.execFileSync("wsl.exe", ["-e","wslpath","-w", t], …) | Windows-only by construction. Path conversion for POSIX-shaped paths, reached from the diagnostics helper and the file-open guard — so WSL / remote workspaces hit it repeatedly |
| 2730648 | uM.execFileSync("git", ["config","--global","core.excludesFile"], {encoding:"utf8", timeout:2000}) | once per session (memoised) |
| 2806047 | SM.execFileSync(rg, ["--version"], {stdio:"ignore"}) | once per session (memoised ripgrep probe) |
| 1193133 | EI.execFileSync("where", [name], {stdio:"ignore"}) for rec, then arecord | once per session, audio-backend probe |
| 1194396 | EI.spawn(rec / arecord, …, {stdio:["ignore","pipe","ignore"]}) | voice input, when a rec / arecord backend was found |
| 609379 | Dz.spawn(rg, ["-n","--no-heading","-e", …]) | bundled SDK built-in Grep tool |
| 1308126 | gV.spawn(rg, ["-n","--no-heading","-e", …]) | bundled SDK built-in Grep tool (second copy of the SDK in the bundle) |
Two further omissions are not bugs: Dz.spawn("/bin/bash", …) at ~601959 and gV.spawn("/bin/bash", …) at ~1300704 are the SDK's POSIX shell session and are unreachable on Windows.
Already correct — for contrast
This report is not "add the flag everywhere". Most of the bundle already gets this right, which is what makes the gaps look like oversights rather than policy:
- The main session spawn is hidden.
spawnLocalProcessat ~char 2123183 passeswindowsHide:!0. This is why the symptom reads as random flashing rather than as "Claude started" — every visible window is helper code. - execa's bundled defaults set
windowsHide:!0(~char 511878), so every execa / cross-spawn path in the bundle is fine. - The
pi()execFilewrapper defaults to hidden:windowsHide: r.windowsHide ?? !0(~char 1192209). The right pattern already exists in this codebase. where.exebinary resolution (~char 902762 and ~2164310),git worktree list(~char 2165118), and the MCP stdio transport (windowsHide: process.platform === "win32", ~char 2704997) all set it.
Deliberate windowsHide: false — please keep these
Three call sites set it to false on purpose, and all three are correct. Listing them so it is clear this report is not asking for a blanket rewrite:
- Extension, ~char 2828333 —
openURL()launching$BROWSERviapi(t, [e], {windowsHide:!1})whenvscode.env.remoteNameis set. The point is to show a browser. - CLI binary (
claude.exe2.1.241), byte offset 307825516 — the Claude-in-Chrome detached browser launch. Correct. - CLI binary, byte offset 327324728 — the terminal launcher
L8y(), reached from the deep-link handler whose entire job is to open a terminal window. Correct.
No user-side workaround exists
Worth stating explicitly, because the usual triage response is a settings suggestion:
- No settings key sets
windowsHide. The shippedclaude-code-settings.schema.json(225 KB) has zero occurrences ofwindowsHide,CREATE_NO_WINDOW,hideConsoleornoWindow; so does the extension'spackage.jsoncontributes.configuration. - No environment variable sets it in either the extension bundle or the CLI binary.
processWrapper/CLAUDE_CODE_PROCESS_WRAPPERdoes not cover these. Its own schema description scopes it to "the background-agent supervisor, the sessions and workers it hosts, and the other covered background processes" — an argv prefix for the session / daemon / worker chain, not the extension's helper spawns."claudeCode.useTerminal": trueavoids most of it only by not running the native panel at all, which is not a fix.- Switching the default terminal application to the legacy Console Host reduces a 2340 ms window to a 30 ms flash. That is a mitigation via a Windows-wide setting most users will not want to change, and it does not remove the windows.
Suggested fix
In rough order of durability:
- Add
windowsHide: trueto the 14 call sites above. Mechanical, and a no-op off Windows. - Route helper spawns through the existing
pi()wrapper (~char 1192209), which already defaultswindowsHidetotrueand lets a caller opt out explicitly. The correct pattern is already in the codebase; the gaps are the calls that bypass it. - Add a lint rule / CI check banning direct
child_processcalls in extension code without an explicitwindowsHide, with an allowlist for the deliberatefalsesites. Without this, the next helper added reintroduces the bug — the history in this repo (#15572, #16880, #24708, #44039, #54683, #61005, #63623, #64163, #72331, #82975 all closed, plus #14828 open since 2025-12) shows it is reintroduced repeatedly, one call site at a time. - Optionally, note in the contributing docs that the extension host has no console, so
windowsHideis not optional there the way it effectively is under the CLI.
Related
- #89071 — the highest-frequency instance of this bug, filed separately: the
@-mention ripgrep spawn at ~char 2806166, which fires on keystrokes. If only one call site gets fixed, that is the one. - #87860 — same
@-mention call site, different defect: unbounded ripgrep processes, no debounce, no cache, no cancellation (605 processes in ~2 min, macOS). On Windows the two compound — every one of those processes is also a console window. - Prior CLI-side reports of the same class of bug: #14828, #58606, #66540, #70200, #73709, #73901, #75404, #76423, #78189, #79219, #80925, #83868, #84675, #86192, #87394. None of them covers the VS Code extension's own helper spawns, which is what this report enumerates.