Windows desktop console and terminal process lifecycle handling
Cluster 53555 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #54657 on 29 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #54657. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
The members appear to concern Windows Desktop spawning or leaking terminal and console processes (WindowsTerminal.exe, OpenConsole.exe, conhost.exe), with two titles describing changes to lifecycle cleanup and backend launch behavior. The titles alone don't establish whether the issue is closed out or what the changes accomplish.
Open issues0
Open PRs0
Fixes on record
-
#54657 fix(desktop): launch Windows backend as console python so child consoles are inherited, not flashed
files:
apps/desktop/electron/main.cjs,apps/desktop/electron/windows-child-process.test.cjsShow the diff
-
apps/desktop/electron/main.cjs@@ -1320,12 +1320,12 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null const venvRoot = path.dirname(scriptsDir) - const python = getNoConsoleVenvPython(venvRoot) + const python = getVenvPython(venvRoot) if (!fileExists(python)) return null const root = path.dirname(venvRoot) return { - label: `existing Hermes no-console Python at ${python}`, + label: `existing Hermes Python at ${python}`, command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, @@ -1338,7 +1338,6 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // Surfaced so backendSupportsServe() can read this runtime's source for the // `serve` capability check instead of falling back to a heavyweight probe. root, - readyFile: true, shell: false } } @@ -1622,62 +1621,26 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } -function readVenvHome(venvRoot) { - try { - const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') - const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im) - return match ? match[1].trim() : null - } catch { - return null - } -} - -function getNoConsoleVenvPython(venvRoot) { - if (!IS_WINDOWS) return getVenvPython(venvRoot) - - // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the - // base console ``python.exe``, allocating a conhost/Windows Terminal window - // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly; - // callers put the venv site-packages on PYTHONPATH so imports still resolve. - const baseHome = readVenvHome(venvRoot) - if (baseHome) { - const basePythonw = path.join(baseHome, 'pythonw.exe') - if (fileExists(basePythonw)) return basePythonw - } - - return path.join(venvRoot, 'Scripts', 'pythonw.exe') -} - -function toNoConsolePython(pythonPath) { - if (!IS_WINDOWS || !pythonPath) return pythonPath - - const resolved = String(pythonPath) - if (/pythonw\.exe$/i.test(resolved)) return resolved - - if (/python\.exe$/i.test(resolved)) { - const pythonw = path.join(path.dirname(resolved), 'pythonw.exe') - if (fileExists(pythonw)) return pythonw - } - - return pythonPath -} - -function applyWindowsNoConsoleSpawnHints(backend) { - if (!IS_WINDOWS || !backend?.command) return backend - - const usesHermesModule = - backend.kind === 'python' || - (Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main') - - if (!usesHermesModule) return backend - - backend.command = toNoConsolePython(backend.command) - if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) { - backend.readyFile = true - } - - return backend -} +// Windows console-window flashes are governed by the *parent's* console, not by +// each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every +// console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — +// which flashes a window. A console-subsystem parent (python.exe) instead owns a +// single console that all of its children inherit, so none of them flash. +// +// Note this change adds no new creationflag: the backend spawn is ALREADY wrapped +// in hiddenWindowsChildOptions() (windowsHide: true), but that setting is INERT +// against pythonw.exe — a GUI-subsystem process has no console for it to act on. +// Switching the backend to the venv's console python.exe is what makes the +// existing wrapper load-bearing: with windowsHide the process comes up owning a +// *windowless* console (verified at runtime — it has an attachable console whose +// window handle is NULL), and its children inherit that one windowless console +// instead of each allocating a visible one. +// +// This makes "no flashing windows" a property of the one backend launch rather +// than a flag that has to be remembered at every descendant spawn site. Restoring +// console python also restores stdout, so the backend announces its port on the +// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is +// needed. function getVenvSitePackagesEntries(venvRoot) { const entries = [] @@ -2899,9 +2862,9 @@ function createPythonBackend(root, label, backendArgs, options = {}) { const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) - const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label, command, @@ -2914,7 +2877,7 @@ function createPythonBackend(root, label, backendArgs, options = {}) { root, bootstrap: Boolean(options.bootstrap), shell: false - }) + } } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the @@ -2923,9 +2886,9 @@ function createPythonBackend(root, label, backendArgs, options = {}) { // ensureRuntime() to create / refresh it before launch. function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) - const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython()) + const command = fileExists(venvPython) ? venvPython : findSystemPython() - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, @@ -2938,7 +2901,7 @@ function createActiveBackend(backendArgs) { root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - }) + } } function resolveHermesBackend(backendArgs) { @@ -3045,15 +3008,15 @@ function resolveHermesBackend(backendArgs) { // failure, fall through to step 6 so the bootstrap runner pulls // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. if (canImportHermesCli(python)) { - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `installed hermes_cli module via ${python}`, - command: toNoConsolePython(python), + command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false - }) + } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3087,7 +3050,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -3229,7 +3192,7 @@ async function ensureRuntime(backend) { ) } - backend.command = getNoConsoleVenvPython(VENV_ROOT) + backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -3238,7 +3201,7 @@ async function ensureRuntime(backend) { running: true, error: null }) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } function fetchJson(url, token, options = {}) { -
apps/desktop/electron/windows-child-process.test.cjs@@ -39,34 +39,39 @@ test('desktop background child processes opt into hidden Windows consoles', () = requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) - assert.match(source, /existing Hermes no-console Python at/) - assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/) - assert.match(source, /function toNoConsolePython\(pythonPath\)/) - assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/) - assert.match(source, /function readVenvHome\(venvRoot\)/) - assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/) - assert.match(source, /backendStartFailure/) - assert.match(source, /HERMES_DESKTOP_READY_FILE/) - assert.match(source, /readyFile: true/) assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) }) -test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { +test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { const source = readElectronFile('main.cjs') - const body = source.slice( - source.indexOf('function getNoConsoleVenvPython(venvRoot)'), - source.indexOf('function getVenvSitePackagesEntries(venvRoot)') + + // The flash fix is structural: the backend runs as a console-subsystem + // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it + // owns ONE windowless console that every descendant spawn inherits. Launching + // it as GUI-subsystem pythonw.exe is what made each child allocate (and flash) + // its own console, so the backend command must never be pythonw. + assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe') + assert.doesNotMatch( + source, + /function getNoConsoleVenvPython\b/, + 'pythonw-conversion helper should be gone; console python is launched directly' + ) + assert.doesNotMatch( + source, + /function applyWindowsNoConsoleSpawnHints\b/, + 'pythonw spawn-hint rewriter should be gone' ) - // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a - // conhost); the base pythonw must be resolved first so it never runs. - const baseIdx = body.indexOf('basePythonw') - const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'") - assert.notEqual(baseIdx, -1, 'base pythonw resolution missing') - assert.notEqual(shimIdx, -1, 'venv shim fallback missing') - assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim') + // Console python restores stdout, so the port is announced on the normal + // HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set. + assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path') + + // Both desktop backend launches must still go through hiddenWindowsChildOptions + // so the single backend console is created windowless. + requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) }) test('intentional or interactive desktop child processes stay documented', () => { @@ -84,5 +89,5 @@ test('bootstrap PowerShell runner hides Windows console children', () => { const source = readElectronFile('bootstrap-runner.cjs') assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - requireHiddenChildOptions(source, 'spawn(ps, fullArgs') + requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) })
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#53619 fix(windows): tighten desktop terminal lifecycle cleanup · done elsewhere →
#54657,#54220
Closed issues · 1
- Closed#53555Windows Desktop can leak WindowsTerminal.exe -Embedding UI and spawn 100+ OpenConsole.exe -Embedding / conhost.exe processes
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #54657
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 29 Jun 2026