On Windows you may see a stream of console windows opening and closing across
the desktop — often more than a dozen at once — that grab foreground focus and
render as unusable, half-drawn terminals. In Task Manager they appear as
OpenConsole.exe processes started with -Embedding.
This document explains the mechanism, how to confirm what is causing it on your machine, and the two fixes.
OpenConsole.exe -Embedding is not a program anything launches on purpose. It
is Windows' default-terminal handoff, and it fires whenever all three of
these are true:
- A process without a console of its own spawns a child, and
- the child is a console application (
git,gh,psql,ffmpeg,where,taskkill,cmd, a Node CLI…), and - the spawn does not pass
CREATE_NO_WINDOW.
Windows must then allocate a brand-new console. Instead of drawing it with the
classic conhost.exe, it hands the console off over COM to whatever is
configured as the Default terminal application. On Windows 11 that is
Windows Terminal — including under the default {00000000-…} "Let Windows
decide" value. The handoff starts OpenConsole.exe -Embedding as the COM local
server and a Windows Terminal window to host it, the window takes foreground
focus, and both die when the short-lived child exits.
One git status is a flicker. A background process doing this a few times a
second is a desktop that will not hold focus.
Condition 1 is permanent for PortOS: every PortOS app is a PM2 fork, and PM2
forks have no console. So on Windows, any PortOS spawn that omits
windowsHide: true produces a focus-stealing window.
Console hosts are far too short-lived to catch by polling Get-Process. Use a
WMI creation-event trace, which misses nothing:
$q = "SELECT * FROM __InstanceCreationEvent WITHIN 0.05 WHERE TargetInstance ISA 'Win32_Process'"
Register-CimIndicationEvent -Query $q -SourceIdentifier Trace | Out-Null
$deadline = (Get-Date).AddSeconds(60)
while ((Get-Date) -lt $deadline) {
foreach ($e in (Get-Event -SourceIdentifier Trace -ErrorAction SilentlyContinue)) {
$t = $e.SourceEventArgs.NewEvent.TargetInstance
"{0} pid={1} ppid={2} {3} :: {4}" -f $e.TimeGenerated.ToString('HH:mm:ss.fff'),
$t.ProcessId, $t.ParentProcessId, $t.Name, $t.CommandLine
Remove-Event -EventIdentifier $e.EventIdentifier
}
Start-Sleep -Milliseconds 250
}
Unregister-Event -SourceIdentifier TraceTwo things to read out of the trace:
- Every
OpenConsole.exe -Embeddingline hasppidpointing at the DCOM launcher (svchost.exe), not at the app that caused it. That is the COM activation, and it is why the parent chain never names the real culprit. - Attribute by walking the console child's ancestry instead — group the
git.exe/cmd.exe/where.execreations by their root ancestor. That is what identifies the actual spawner.
PortOS is frequently not the only contributor. Any tool that polls git in the background does the same thing; editor and AI-assistant desktop apps are common sources, and one of them can easily out-spawn PortOS several times over. Attribute before you assume.
Setting the default terminal to the classic console host removes step 3's
consequence for everything on the machine, not just PortOS. Windows still
allocates a console, but draws it with conhost.exe, which does not COM-activate
Windows Terminal and does not steal focus.
npm run fix:windows-console # set default terminal to Windows Console Host
npm run fix:windows-console -- --show # print the current setting only
npm run fix:windows-console -- --revert # restore "Let Windows decide"The equivalent manual route is Settings → System → For developers → Terminal → Windows Console Host, or in Windows Terminal, Settings → Startup → Default terminal application.
This only writes HKCU\Console\%%Startup for the current user, changes no
system state, and takes effect for newly launched console apps. It does not stop
you from using Windows Terminal — it only stops apps from being forced into it
when they allocate a console programmatically.
Fix 2 (PortOS-side): every spawn defaults to hidden
PortOS server code must not import child_process directly. It imports
server/lib/childProcess.js, a drop-in
replacement that defaults windowsHide: true (i.e. CREATE_NO_WINDOW) on every
spawn / spawnSync / fork / exec / execSync / execFile /
execFileSync. exec and execFile keep their util.promisify.custom hooks,
so promisify(execFile) still resolves to { stdout, stderr }. An explicit
windowsHide: false is honored.
server/lib/childProcess.guards.test.js fails the build if a server runtime file
imports child_process directly. This bug was fixed twice before by sweeping
windowsHide: true across call sites (v1.5.x, v1.6.7) and regressed both times,
because nothing prevented the next new file from omitting it. Owning the import
is the version of the rule that new code cannot silently skip.
Carve-outs, all enforced rather than exempted. Trees that cannot import the
wrapper are held to a per-call-site rule instead — every spawn/exec
family call must carry windowsHide itself:
server/lib/aiToolkit/is vendored and contractually self-contained (no imports out to other PortOS modules), so it applieswindowsHideinline.autofixer/andbrowser/are separate packages with their ownpackage.json, so they cannot reachserver/lib/either — but both are PM2-forked apps (ecosystem.config.cjs), which puts them in exactly the console-less blast radius above.
The per-call-site check is deliberate: a file-level "mentions windowsHide
somewhere" test is the weak form of the rule, and would let a second spawn added
to an already-compliant file through unchecked.
-
shell: truewith a barepm2is banned separately, but not becausewindowsHidefails there. Measured on Windows 11: eightshell: truespawns from a console-less parent produce eightOpenConsole.exe -Embeddinghandoffs withoutwindowsHide, and zero with it —shell: truemakescmd.exethe direct child, soCREATE_NO_WINDOWapplies to it and thepm2.cmd/nodegrandchildren inherit its hidden console. The v1.6.7 sites flashed because they passed nowindowsHideat all. The rule stands on its other merits:execPm2/spawnPm2(server/services/pm2.js) execnode pm2/bin/pm2directly, dropping two process hops and the PATH ambiguity of resolving a barepm2through a shell.This distinction matters. If you believe
windowsHideis unreliable, you start layering redundant per-call special cases on top of the wrapper — which is the failure mode the wrapper exists to end.
node-pty / ConPTY sessions. A PTY does allocate its own console host, but
always with --headless, which never triggers the terminal handoff and never
draws a window. Web shells and TUI agent sessions were never part of this
symptom, and there is nothing to change there.
That holds because the pinned node-pty is ConPTY-only — its Windows agent has
no winpty fallback path. It is a property of this dependency, not of PTYs in
general, so re-check it on a node-pty major bump. All PTY spawns go through the
one module (server/services/shell.js, server/cos-runner/index.js,
server/lib/tuiPromptRunner.js, server/lib/tuiUsageScrape.js), so the blast
radius of that assumption is real.
detached: true spawns. Windows ignores CREATE_NO_WINDOW when
DETACHED_PROCESS is set. Those children are safe because they get no console
at all — not because windowsHide is doing the work. Don't remove detached
from a spawn and assume the flag was what protected it.
No. How you start PortOS makes no difference — npm start, pm2 start, a
shortcut, or a PowerShell profile all end at the same place, because the spawns
that flash come from PM2-forked children, and PM2 forks are console-less no
matter what launched the daemon. Running the launch command from an elevated
prompt, from Windows Terminal, or with -WindowStyle Hidden changes nothing.
The two fixes above are the whole story: Fix 1 changes what Windows does with a newly allocated console, and Fix 2 stops PortOS from allocating one at all.