mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
fix(desktop): launch Windows backend as console python so child consoles are inherited, not flashed
The recurring Windows desktop console-flash bug (#54220) is governed by the *parent's* console, not by each child spawn. The desktop backend was launched as GUI-subsystem pythonw.exe, which has no console at all — so every console-subsystem child it spawns (git, gh, cmd, wmic, powershell, ...) had to allocate its own console, flashing a window. That is why the fix had become an endless per-call-site sweep of CREATE_NO_WINDOW flags: each leaf spawn was papering over a missing console on the root. Launch the backend as the venv's console python.exe instead. Under the existing hiddenWindowsChildOptions() wrapper (windowsHide: true -> CREATE_NO_WINDOW) the backend owns a single *windowless* console, and every descendant spawn inherits it instead of allocating a visible one. This makes "no flashing windows" a property of the one backend launch rather than a flag that must be remembered at every spawn site — including spawns inside third-party libraries that no call-site sweep can reach. Verified on Windows 11 25H2 (Windows Terminal default): with the per-site hide flag forcibly neutered, the canonical culprits (git/gh/cmd/wmic/powershell) spawned naively and none flashed, while the same naive spawn from the old console-less pythonw parent did flash — isolating the parent console as the cause. Two premises behind the old pythonw approach did not hold up on current Windows and are dropped here: - The venv Scripts\python.exe uv shim, under CREATE_NO_WINDOW, re-execs base python *windowless* — it does not flash a conhost (the #52239 concern), so the base-pythonw detour is unnecessary. - Console python restores stdout, so the backend announces its port on the normal HERMES_DASHBOARD_READY stdout line; the pythonw-only ready-file side channel is no longer needed and the readyFile opt-in is removed. Removes the now-dead pythonw machinery (getNoConsoleVenvPython, toNoConsolePython, applyWindowsNoConsoleSpawnHints, readVenvHome) and updates the test to assert the new invariant: backend command is never pythonw, both backend spawns still go through hiddenWindowsChildOptions, and no backend opts into the ready-file path. Scope: this fixes the high-frequency backend-descendant flash classes. The updater/UAC handoff (#54543) and embedded-terminal PTY accumulation (#53555) classes have separate root causes and are unaffected.
This commit is contained in:
parent
20b03d9aee
commit
aa2ae36c3f
2 changed files with 59 additions and 91 deletions
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue