diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs index a4899e8657a..68556f6bcbc 100644 --- a/apps/desktop/electron/backend-ready.cjs +++ b/apps/desktop/electron/backend-ready.cjs @@ -1,3 +1,5 @@ +const fs = require('node:fs') + const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m // The announcement clock starts the instant the backend process is spawned — @@ -94,8 +96,75 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) }) } +function readDashboardReadyFile(readyFile) { + if (!readyFile) return null + try { + const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8')) + const port = Number(parsed?.port) + return Number.isInteger(port) && port > 0 ? port : null + } catch { + return null + } +} + +function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) { + return new Promise((resolve, reject) => { + let done = false + let interval = null + + function cleanup() { + if (done) return + done = true + clearTimeout(timer) + if (interval) clearInterval(interval) + child.off('exit', onExit) + child.off('error', onError) + } + + function check() { + const port = readDashboardReadyFile(readyFile) + if (port) { + cleanup() + resolve(port) + } + } + + function onExit(code, signal) { + cleanup() + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + } + + function onError(err) { + cleanup() + reject(err) + } + + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`)) + }, timeoutMs) + + child.on('exit', onExit) + child.on('error', onError) + interval = setInterval(check, 50) + if (typeof interval.unref === 'function') interval.unref() + check() + }) +} + +function waitForDashboardPortAnnouncement(child, options = {}) { + const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() + if (options.readyFile) { + return waitForDashboardReadyFile(options.readyFile, child, timeoutMs) + } + return waitForDashboardPort(child, timeoutMs) +} + module.exports = { waitForDashboardPort, + waitForDashboardPortAnnouncement, + waitForDashboardReadyFile, + readDashboardReadyFile, resolvePortAnnounceTimeoutMs, DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, diff --git a/apps/desktop/electron/backend-ready.test.cjs b/apps/desktop/electron/backend-ready.test.cjs index 8f6267b7929..2252888096c 100644 --- a/apps/desktop/electron/backend-ready.test.cjs +++ b/apps/desktop/electron/backend-ready.test.cjs @@ -14,9 +14,15 @@ const test = require('node:test') const assert = require('node:assert/strict') const { EventEmitter } = require('node:events') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') const { + readDashboardReadyFile, waitForDashboardPort, + waitForDashboardPortAnnouncement, + waitForDashboardReadyFile, resolvePortAnnounceTimeoutMs, DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, @@ -119,3 +125,75 @@ test('a late announcement after timeout does not throw (listeners torn down)', a child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n') }) }) + +// --------------------------------------------------------------------------- +// ready-file port announcement +// --------------------------------------------------------------------------- + +function mkTmpReadyFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-')) + return { + dir, + file: path.join(dir, 'ready.json'), + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) + } +} + +test('readDashboardReadyFile returns a valid port from JSON', () => { + const tmp = mkTmpReadyFile() + try { + fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 })) + assert.equal(readDashboardReadyFile(tmp.file), 4567) + } finally { + tmp.cleanup() + } +}) + +test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => { + const tmp = mkTmpReadyFile() + try { + assert.equal(readDashboardReadyFile(tmp.file), null) + fs.writeFileSync(tmp.file, '{') + assert.equal(readDashboardReadyFile(tmp.file), null) + fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 })) + assert.equal(readDashboardReadyFile(tmp.file), null) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardReadyFile resolves when the ready file appears', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardReadyFile(tmp.file, child, 1000) + setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20) + assert.equal(await p, 8765) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardPortAnnouncement uses ready file when provided', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 }) + setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20) + assert.equal(await p, 9876) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardReadyFile(tmp.file, child, 1000) + child.emit('exit', 1, null) + await assert.rejects(p, /exited before port announcement/) + } finally { + tmp.cleanup() + } +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ce42e3474dc..c07b988e170 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -38,7 +38,7 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { createLinkTitleWindow } = require('./link-title-window.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPort } = require('./backend-ready.cjs') +const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') @@ -744,6 +744,9 @@ let rendererReloadTimes = [] // instead of re-running install.ps1 in a hot loop. Cleared explicitly by // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null +// Latched non-bootstrap backend spawn failure — stops getConnection() from +// respawning hermes dashboard children in a tight loop while boot is broken. +let backendStartFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null @@ -1254,6 +1257,39 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } +function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { + if (!IS_WINDOWS || !command || isCommandScript(command)) return null + + const resolved = path.resolve(String(command)) + if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null + + const scriptsDir = path.dirname(resolved) + if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null + + const venvRoot = path.dirname(scriptsDir) + const python = getNoConsoleVenvPython(venvRoot) + if (!fileExists(python)) return null + + const root = path.dirname(venvRoot) + return { + label: `existing Hermes no-console Python at ${python}`, + command: python, + args: ['-m', 'hermes_cli.main', ...dashboardArgs], + bootstrap: false, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [ + ...(directoryExists(root) ? [root] : []), + ...getVenvSitePackagesEntries(venvRoot) + ], + venvRoot + }), + kind: 'python', + readyFile: true, + shell: false + } +} + function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) return null @@ -1474,6 +1510,99 @@ 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) + + // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages + // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv + // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched. + const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe') + if (fileExists(venvPythonw)) return venvPythonw + + const baseHome = readVenvHome(venvRoot) + if (baseHome) { + const basePythonw = path.join(baseHome, 'pythonw.exe') + if (fileExists(basePythonw)) return basePythonw + } + + return venvPythonw +} + +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 +} + +function getVenvSitePackagesEntries(venvRoot) { + const entries = [] + if (!venvRoot) return entries + + if (IS_WINDOWS) { + const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') + if (directoryExists(sitePackages)) entries.push(sitePackages) + return entries + } + + const version = (() => { + try { + const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') + const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + return match ? match[1].trim() : null + } catch { + return null + } + })() + if (version) { + const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') + if (directoryExists(sitePackages)) entries.push(sitePackages) + } + return entries +} + +function makeDashboardReadyFile() { + const dir = path.join(app.getPath('userData'), 'backend-ready') + fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) +} + // resolveGitBinary — locate git.exe on Windows. A fresh installer-driven // install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on // PATH), so a bare spawn('git') ENOENTs and self-update checks fail with @@ -2590,20 +2719,25 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { const python = findPythonForRoot(root) if (!python) return null - return { + const venvRoot = path.join(root, 'venv') + const venvPython = getVenvPython(venvRoot) + const command = + IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label, - command: python, + command, args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [root], - venvRoot: path.join(root, 'venv') + venvRoot }), root, bootstrap: Boolean(options.bootstrap), shell: false - } + }) } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the @@ -2612,11 +2746,14 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { // ensureRuntime() to create / refresh it before launch. function createActiveBackend(dashboardArgs) { const venvPython = getVenvPython(VENV_ROOT) + const command = fileExists(venvPython) + ? getNoConsoleVenvPython(VENV_ROOT) + : toNoConsolePython(findSystemPython()) - return { + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, - command: fileExists(venvPython) ? venvPython : findSystemPython(), + command, args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, @@ -2626,7 +2763,7 @@ function createActiveBackend(dashboardArgs) { root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - } + }) } function resolveHermesBackend(dashboardArgs) { @@ -2687,6 +2824,11 @@ function resolveHermesBackend(dashboardArgs) { } if (hermesCommand) { + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) + if (unwrapped) { + return unwrapped + } + // Smoke-test the candidate before trusting it. A `hermes` shim // left behind by a half-uninstalled pip install (or a venv // entry-point pointing at a deleted interpreter) still resolves @@ -2696,7 +2838,7 @@ function resolveHermesBackend(dashboardArgs) { // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { - return { + return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, args: dashboardArgs, @@ -2726,15 +2868,15 @@ function resolveHermesBackend(dashboardArgs) { // 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 { + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label: `installed hermes_cli module via ${python}`, - command: python, + command: toNoConsolePython(python), args: ['-m', 'hermes_cli.main', ...dashboardArgs], bootstrap: false, env: {}, shell: false - } + }) } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -2768,7 +2910,7 @@ function resolveHermesBackend(dashboardArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return backend + return applyWindowsNoConsoleSpawnHints(backend) } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -2908,7 +3050,7 @@ async function ensureRuntime(backend) { ) } - backend.command = venvPython + backend.command = getNoConsoleVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -2917,7 +3059,7 @@ async function ensureRuntime(backend) { running: true, error: null }) - return backend + return applyWindowsNoConsoleSpawnHints(backend) } @@ -4831,6 +4973,7 @@ function resetBootProgressForReconnect() { function resetHermesConnection() { connectionPromise = null + backendStartFailure = null if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') @@ -4992,6 +5135,7 @@ async function spawnPoolBackend(profile, entry) { const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() + const readyFile = backend.readyFile ? makeDashboardReadyFile() : null rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) @@ -5012,7 +5156,8 @@ async function spawnPoolBackend(profile, entry) { // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] @@ -5045,7 +5190,10 @@ async function spawnPoolBackend(profile, entry) { }) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPort(child), startFailed]) + const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { + fs.unlink(readyFile, () => {}) + } entry.port = port const baseUrl = `http://127.0.0.1:${port}` @@ -5158,6 +5306,9 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { + throw backendStartFailure + } if (connectionPromise) return connectionPromise connectionPromise = (async () => { @@ -5211,6 +5362,7 @@ async function startHermes() { const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() + const readyFile = backend.readyFile ? makeDashboardReadyFile() : null await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) @@ -5237,7 +5389,8 @@ async function startHermes() { // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] @@ -5293,12 +5446,16 @@ async function startHermes() { await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed]) + const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed]) + if (readyFile) { + fs.unlink(readyFile, () => {}) + } const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true + backendStartFailure = null const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, @@ -5324,6 +5481,7 @@ async function startHermes() { } })().catch(error => { const message = error instanceof Error ? error.message : String(error) + backendStartFailure = error instanceof Error ? error : new Error(message) updateBootProgress( { error: message, @@ -5889,6 +6047,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') await teardownPrimaryBackendAndWait() bootstrapFailure = null + backendStartFailure = null bootstrapState = { active: false, manifest: null, @@ -5915,6 +6074,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } bootstrapFailure = null + backendStartFailure = null resetHermesConnection() return { ok: true } }) diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 4239da56e23..383f2f2d3d0 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -12,7 +12,8 @@ function readElectronFile(name) { } function requireHiddenChildOptions(source, needle) { - const index = source.indexOf(needle) + const match = needle instanceof RegExp ? needle.exec(source) : null + const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle) assert.notEqual(index, -1, `missing call site: ${needle}`) const snippet = source.slice(index, index + 700) assert.match( @@ -28,14 +29,28 @@ test('desktop background child processes opt into hidden Windows consoles', () = assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) requireHiddenChildOptions(source, "execFileSync(\n 'reg'") - requireHiddenChildOptions(source, 'execFileSync(pyExe') - requireHiddenChildOptions(source, 'spawn(resolveGitBinary()') + requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) + requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/) requireHiddenChildOptions(source, "execFileSync('taskkill'") - requireHiddenChildOptions(source, 'spawn(command, args') + requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/) requireHiddenChildOptions(source, "spawn('curl'") - requireHiddenChildOptions(source, 'spawn(backend.command, backend.args') - requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args') - requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']") + requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) + + assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/) + 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', \.\.\.dashboardArgs\]/) }) test('intentional or interactive desktop child processes stay documented', () => { diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 0a8df746b3f..2e2c63705d2 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react' import { atom } from 'nanostores' import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { $terminalTakeover } from '../store' + import { TerminalTab } from './index' /** @@ -54,6 +56,7 @@ const sameRect = (a: Rect | null, b: Rect) => export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) { const slot = useStore($slot) + const terminalTakeover = useStore($terminalTakeover) const [rect, setRect] = useState(null) const [ready, setReady] = useState(false) @@ -111,12 +114,12 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm contain: 'layout size paint' } - // Defer mount until real dims — booting xterm at 0×0 starts the shell at - // 80×24, then the first ResizeObserver SIGWINCH redraws the prompt on a - // new line. After first measurement we keep it mounted forever. + // Defer mount until the terminal sidebar is open and the slot has real dims. + // Booting xterm/node-pty at 0×0 starts the shell at 80×24 and spawns a + // visible conhost on Windows even when the pane is collapsed. return (
- {ready && } + {terminalTakeover && ready && }
) } diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 45e0ad7e678..86cf75b4a9b 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -580,7 +580,6 @@ export function startUpdatePoller(): void { } pollerStarted = true - void checkUpdates() void checkBackendUpdates() void refreshDesktopVersion() bridge.onProgress(ingestProgress) @@ -600,7 +599,6 @@ export function startUpdatePoller(): void { window.addEventListener('focus', onFocus) backgroundTimer = setInterval(() => { - void checkUpdates() void checkBackendUpdates() }, 30 * 60 * 1000) } @@ -626,7 +624,6 @@ function onFocus() { } lastFocusAt = now - void checkUpdates() void checkBackendUpdates() void refreshDesktopVersion() } diff --git a/gateway/run.py b/gateway/run.py index ec2b6524416..dddba78440e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5130,6 +5130,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew watcher = textwrap.dedent( """ import os, subprocess, sys, time + from hermes_cli._subprocess_compat import windows_detach_flags_without_breakaway pid = int(sys.argv[1]) cmd = sys.argv[2:] deadline = time.monotonic() + 120 @@ -5165,14 +5166,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not _alive(pid): break time.sleep(0.2) - _CREATE_NEW_PROCESS_GROUP = 0x00000200 - _DETACHED_PROCESS = 0x00000008 - _CREATE_NO_WINDOW = 0x08000000 subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - creationflags=_CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW, + creationflags=windows_detach_flags_without_breakaway(), ) """ ).strip() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 63ea644ec0a..3b7c77dc888 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -723,6 +723,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: import subprocess import sys import time + from hermes_cli._subprocess_compat import ( + windows_detach_flags, + windows_detach_flags_without_breakaway, + ) pid = int(sys.argv[1]) cmd = sys.argv[2:] @@ -747,18 +751,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: "stderr": subprocess.DEVNULL, } if sys.platform == "win32": - _CREATE_NEW_PROCESS_GROUP = 0x00000200 - _DETACHED_PROCESS = 0x00000008 - _CREATE_NO_WINDOW = 0x08000000 - _CREATE_BREAKAWAY_FROM_JOB = 0x01000000 - _flags = ( - _CREATE_NEW_PROCESS_GROUP - | _DETACHED_PROCESS - | _CREATE_NO_WINDOW - | _CREATE_BREAKAWAY_FROM_JOB - ) try: - _popen_kwargs["creationflags"] = _flags + _popen_kwargs["creationflags"] = windows_detach_flags() subprocess.Popen(cmd, **_popen_kwargs) except OSError: # CREATE_BREAKAWAY_FROM_JOB can be rejected with @@ -766,7 +760,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # breakaway. Retry without it — DETACHED_PROCESS et al. # alone are enough in most setups. Mirrors the canonical # fallback in gateway_windows._spawn_detached. - _popen_kwargs["creationflags"] = _flags & ~_CREATE_BREAKAWAY_FROM_JOB + _popen_kwargs["creationflags"] = windows_detach_flags_without_breakaway() subprocess.Popen(cmd, **_popen_kwargs) else: _popen_kwargs["start_new_session"] = True diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 994ab6e1c50..c7959889523 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -3,21 +3,20 @@ This mirrors the contract exposed by ``launchd_install`` / ``launchd_start`` / ``launchd_status`` etc. on macOS and ``systemd_install`` / ``systemd_start`` on Linux. It uses ``schtasks`` under the hood with ``/SC ONLOGON`` and restart-on- -failure XML settings, and falls back to a ``%APPDATA%\\...\\Startup\\.cmd`` +failure XML settings, and falls back to a ``%APPDATA%\\...\\Startup\\.vbs`` dropper when Scheduled Task creation is denied (locked-down corporate boxes). Design notes ------------ * ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the - CURRENT USER's next logon without any elevation prompt. We also - ``schtasks /Run`` immediately after install so the gateway starts right - away without waiting for the next logon. -* We write two files: a shared ``gateway.cmd`` wrapper script (cwd + env + the - actual ``python -m hermes_cli.main gateway run --replace`` invocation) and - EITHER a schtasks entry pointing at it OR a Startup-folder ``.cmd`` that - spawns it detached. + CURRENT USER's next logon without any elevation prompt. Manual starts and + install ``--start-now`` use the direct detached ``pythonw`` launcher instead + of ``schtasks /Run`` so start/restart behavior is consistent. +* We write a shared ``gateway.cmd`` wrapper plus a console-less ``gateway.vbs`` + launcher. Scheduled Task and Startup-folder persistence both route through + VBS/wscript; immediate manual starts route through direct ``subprocess`` spawn. * Status = merge of "is the schtasks entry registered?" + "is the startup - .cmd present?" + "is there a gateway process running?" so the status + login item present?" + "is there a gateway process running?" so the status command keeps working regardless of which install path was taken. * Quoting is tricky: schtasks parses ``/TR`` itself and cmd.exe parses the generated ``gateway.cmd``. Those are DIFFERENT parsers. We keep two @@ -40,6 +39,12 @@ import time from pathlib import Path from xml.sax.saxutils import escape +from hermes_cli._subprocess_compat import ( + windows_detach_flags, + windows_detach_flags_without_breakaway, + windows_hide_flags, +) + # Short timeouts: schtasks occasionally wedges and we don't want to hang forever. _SCHTASKS_TIMEOUT_S = 15 _SCHTASKS_NO_OUTPUT_TIMEOUT_S = 30 @@ -80,6 +85,31 @@ def _assert_windows() -> None: raise RuntimeError("gateway_windows is Windows-only") +def _preserve_hermes_home_path(path: str | Path) -> str: + """Render Hermes-owned paths under the configured HERMES_HOME spelling. + + Windows installs may keep ``%LOCALAPPDATA%\\hermes`` as a symlink/junction to + another drive. Runtime state should still identify itself by the configured + AppData path, so launcher files must not bake in the resolved target when a + path lives under HERMES_HOME. + """ + candidate = Path(path) + try: + from hermes_cli.config import get_hermes_home + + home = Path(get_hermes_home()) + resolved_home = home.resolve() + resolved_candidate = candidate.resolve() + home_key = os.path.normcase(str(resolved_home)) + candidate_key = os.path.normcase(str(resolved_candidate)) + if os.path.commonpath([home_key, candidate_key]) == home_key: + rel = os.path.relpath(str(resolved_candidate), str(resolved_home)) + return str(home / rel) + except Exception: + pass + return str(candidate) + + # --------------------------------------------------------------------------- # Quoting helpers (two DIFFERENT parsers — do not mix) # --------------------------------------------------------------------------- @@ -141,7 +171,7 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: # CREATE_NO_WINDOW avoids a flashing console window when the CLI # is itself hosted in a TUI. See tools/browser_tool.py for the # same pattern and the windows-subprocess-sigint-storm.md ref. - creationflags=0x08000000, # CREATE_NO_WINDOW + creationflags=windows_hide_flags(), ) return (proc.returncode, proc.stdout or "", proc.stderr or "") except subprocess.TimeoutExpired: @@ -274,7 +304,7 @@ def _sanitize_filename(value: str) -> str: def get_task_script_path() -> Path: - """The generated ``gateway.cmd`` wrapper that the schtasks entry invokes. + """The generated ``gateway.cmd`` wrapper kept beside the VBS launcher. Lives under ``%LOCALAPPDATA%\\hermes\\gateway-service\\.cmd`` (or ``/gateway-service/.cmd`` so per-profile @@ -308,6 +338,11 @@ def _startup_dir() -> Path: def get_startup_entry_path() -> Path: + _assert_windows() + return _startup_dir() / f"{_sanitize_filename(get_task_name())}.vbs" + + +def _legacy_startup_entry_path() -> Path: _assert_windows() return _startup_dir() / f"{_sanitize_filename(get_task_name())}.cmd" @@ -322,14 +357,18 @@ def _stable_gateway_working_dir(project_root: Path) -> str: Mirror the POSIX service invariant: anchor at ``HERMES_HOME`` whenever it exists so Scheduled Task / Startup launches do not fail at the ``cd`` step after a transient checkout or worktree is moved away. Fall back to the - source checkout only if ``HERMES_HOME`` cannot be resolved yet. + source checkout only if ``HERMES_HOME`` cannot be used yet. Preserve the + configured spelling instead of resolving symlinks so AppData installs backed + by a junction/symlink still identify themselves as AppData. """ from hermes_cli.config import get_hermes_home try: home = get_hermes_home() - if home and Path(home).is_dir(): - return str(Path(home).resolve()) + if home: + home_path = Path(home) + if home_path.is_dir(): + return str(home_path) except Exception: pass return str(project_root) @@ -365,8 +404,11 @@ def _build_gateway_cmd_script( pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) # VIRTUAL_ENV lets the gateway's own python detection find the venv # if someone imports hermes_constants-based logic during startup. - lines.append(f'set "VIRTUAL_ENV={venv_dir}"') - pythonpath_entries = [str(Path(__file__).resolve().parent.parent), *extra_pythonpath] + lines.append(f'set "VIRTUAL_ENV={_preserve_hermes_home_path(venv_dir)}"') + pythonpath_entries = [ + _preserve_hermes_home_path(Path(__file__).resolve().parent.parent), + *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath], + ] lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"') prog_args = [pythonw_path, "-m", "hermes_cli.main"] @@ -427,8 +469,10 @@ def _build_gateway_vbs_script( # list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run. command_line = subprocess.list2cmdline(prog_args) - repo_root = str(Path(__file__).resolve().parent.parent) - static_pythonpath = os.pathsep.join([repo_root, *extra_pythonpath]) + repo_root = _preserve_hermes_home_path(Path(__file__).resolve().parent.parent) + static_pythonpath = os.pathsep.join( + [repo_root, *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath]] + ) lines = [ f"' {_TASK_DESCRIPTION}", @@ -439,7 +483,7 @@ def _build_gateway_vbs_script( f"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}", f"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}", f"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}", - f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(str(venv_dir))}", + f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(_preserve_hermes_home_path(venv_dir))}", # Mirror the cmd wrapper's ``PYTHONPATH=;%PYTHONPATH%``: chain onto # whatever PYTHONPATH the task environment already carries, at runtime. f"existing_pp = env.Item({_quote_vbs_string('PYTHONPATH')})", @@ -457,26 +501,25 @@ def _build_gateway_vbs_script( def _build_startup_launcher(script_path: Path) -> str: - """The tiny .cmd that goes in the Startup folder. Just minimizes and chains. + """The tiny .vbs that goes in the Startup folder and chains hidden. Defense-in-depth: bail out silently if the target script is gone. Test fixtures historically wrote Startup entries pointing at pytest tmp_path directories that vanish after the test session. Without the existence - guard, every subsequent Windows login flashes a cmd.exe window that - fails to find the target. The check + ``exit /b 0`` keeps that case - silent. + guard, every subsequent Windows login could attempt a stale launcher. The + check + ``WScript.Quit 0`` keeps that case silent. """ - quoted_target = _quote_cmd_script_arg(str(script_path)) + target = str(script_path.with_suffix(".vbs")) + command = subprocess.list2cmdline(["wscript.exe", target]) lines = [ - "@echo off", - f"rem {_TASK_DESCRIPTION}", - # If the wrapper script is gone (typical for stale entries from - # uninstalled/migrated installs), silently no-op instead of - # flashing a cmd window with a "file not found" error. - f"if not exist {quoted_target} exit /b 0", - # ``start "" /min`` detaches with a minimized console window. - # ``/d /c`` on cmd.exe skips AUTORUN and runs the target script once. - f'start "" /min cmd.exe /d /c {quoted_target}', + f"' {_TASK_DESCRIPTION}", + "Option Explicit", + "Dim fso, sh, target", + f"target = {_quote_vbs_string(target)}", + 'Set fso = CreateObject("Scripting.FileSystemObject")', + "If Not fso.FileExists(target) Then WScript.Quit 0", + 'Set sh = CreateObject("WScript.Shell")', + f"sh.Run {_quote_vbs_string(command)}, 0, False", ] return "\r\n".join(lines) + "\r\n" @@ -492,9 +535,9 @@ def _write_task_script() -> Path: get_python_path, ) - python_path = get_python_path() + python_path = _preserve_hermes_home_path(get_python_path()) working_dir = _stable_gateway_working_dir(PROJECT_ROOT) - hermes_home = str(Path(get_hermes_home()).resolve()) + hermes_home = str(Path(get_hermes_home())) profile_arg = _profile_arg(hermes_home) content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg) @@ -503,9 +546,9 @@ def _write_task_script() -> Path: tmp.write_text(content, encoding="utf-8", newline="") tmp.replace(script_path) - # Also render the console-less .vbs launcher the Scheduled Task runs via - # wscript.exe (issue #45599 fix A). The .cmd above stays for the - # Startup-folder fallback and direct /Run paths. + # Also render the console-less .vbs launcher used by Scheduled Task and the + # Startup-folder fallback via wscript.exe (issue #45599 fix A). The .cmd + # wrapper stays as a generated helper/compatibility artifact. vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg) vbs_path = script_path.with_suffix(".vbs") vbs_tmp = vbs_path.with_name(vbs_path.name + ".tmp") @@ -614,7 +657,7 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st # the final error if creation also fails. user = _resolve_task_user() # The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not - # the .cmd. The .cmd stays for the Startup-folder fallback and direct /Run. + # the .cmd. Immediate manual starts use _spawn_detached(). launcher_path = script_path.with_suffix(".vbs") xml_path = _write_scheduled_task_xml(task_name, launcher_path, user) base = ["/Create", "/F", "/TN", task_name, "/XML", str(xml_path)] @@ -648,6 +691,12 @@ def _install_startup_entry(script_path: Path) -> Path: tmp = entry.with_suffix(".tmp") tmp.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="") tmp.replace(entry) + legacy_entry = _legacy_startup_entry_path() + try: + if legacy_entry.exists(): + legacy_entry.unlink() + except OSError: + pass return entry @@ -732,10 +781,12 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: get_python_path, ) - python_exe, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path()) - project_root = str(PROJECT_ROOT) + python_exe, venv_dir, extra_pythonpath = _resolve_detached_python( + _preserve_hermes_home_path(get_python_path()) + ) + project_root = _preserve_hermes_home_path(PROJECT_ROOT) working_dir = _stable_gateway_working_dir(PROJECT_ROOT) - hermes_home = str(Path(get_hermes_home()).resolve()) + hermes_home = str(Path(get_hermes_home())) profile_arg = _profile_arg(hermes_home) argv = [python_exe, "-m", "hermes_cli.main"] @@ -747,9 +798,14 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: "HERMES_HOME": hermes_home, "PYTHONIOENCODING": "utf-8", "HERMES_GATEWAY_DETACHED": "1", - "VIRTUAL_ENV": str(venv_dir), + "VIRTUAL_ENV": _preserve_hermes_home_path(venv_dir), } - _prepend_pythonpath(env_overlay, [project_root, *extra_pythonpath] if extra_pythonpath else [project_root]) + _prepend_pythonpath( + env_overlay, + [project_root, *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath]] + if extra_pythonpath + else [project_root], + ) return argv, working_dir, env_overlay @@ -785,7 +841,7 @@ def _spawn_detached(script_path: Path | None = None) -> int: # job teardown from reaping us; # some Windows Terminal versions # wrap their children in a job). - flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000 + flags = windows_detach_flags() # Redirect any stray stdout/stderr output to a sidecar log. Python's # logging module writes to gateway.log through a FileHandler, so the @@ -814,7 +870,7 @@ def _spawn_detached(script_path: Path | None = None) -> int: # parent's job object doesn't permit breakaway (some Windows # Terminal configs). Retry without the breakaway flag — in most # setups pythonw.exe + DETACHED_PROCESS is enough on its own. - flags_no_breakaway = flags & ~0x01000000 + flags_no_breakaway = windows_detach_flags_without_breakaway() with open(stray_log, "ab", buffering=0) as log_fh: proc = subprocess.Popen( argv, @@ -1045,14 +1101,14 @@ def _report_gateway_start(via: str) -> None: print(f"⚠ Launched gateway via {via}, but no process detected after 6s.") print(" Check the log for startup errors:") from hermes_cli.config import get_hermes_home - print(f" type {Path(get_hermes_home()).resolve()}\\logs\\gateway.log") - print(f" type {Path(get_hermes_home()).resolve()}\\logs\\gateway-stdio.log") + print(f" type {Path(get_hermes_home())}\\logs\\gateway.log") + print(f" type {Path(get_hermes_home())}\\logs\\gateway-stdio.log") def _print_next_steps() -> None: from hermes_cli.config import get_hermes_home - hermes_home = Path(get_hermes_home()).resolve() + hermes_home = Path(get_hermes_home()) print() print("Next steps:") print(" hermes gateway status # Check status") @@ -1064,7 +1120,9 @@ def uninstall() -> None: _assert_windows() task_name = get_task_name() script_path = get_task_script_path() + vbs_script_path = script_path.with_suffix(".vbs") startup_entry = get_startup_entry_path() + legacy_startup_entry = _legacy_startup_entry_path() scheduled_task_removed = False if is_task_registered(): @@ -1091,7 +1149,9 @@ def uninstall() -> None: for path, label in [ (startup_entry, "Windows login item"), + (legacy_startup_entry, "legacy Windows login item"), (script_path, "Task script"), + (vbs_script_path, "Task launcher"), ]: try: path.unlink() @@ -1113,7 +1173,7 @@ def is_task_registered() -> bool: def is_startup_entry_installed() -> bool: - return get_startup_entry_path().exists() + return get_startup_entry_path().exists() or _legacy_startup_entry_path().exists() def is_installed() -> bool: @@ -1171,7 +1231,7 @@ def _print_deep_probes() -> None: from hermes_cli.config import get_hermes_home - home = Path(get_hermes_home()).resolve() + home = Path(get_hermes_home()) pid_path = home / "gateway.pid" lock_path = home / "gateway.lock" state_path = home / "gateway_state.json" @@ -1299,7 +1359,10 @@ def status(deep: bool = False) -> None: if key in info: print(f" {key.title()}: {info[key]}") elif startup_installed: - print(f"✓ Windows login item installed: {get_startup_entry_path()}") + entry = get_startup_entry_path() + if not entry.exists(): + entry = _legacy_startup_entry_path() + print(f"✓ Windows login item installed: {entry}") else: print("✗ Gateway service not installed") @@ -1324,7 +1387,7 @@ def status(deep: bool = False) -> None: def start() -> None: - """Start the gateway. Prefers /Run on the scheduled task if present.""" + """Start the gateway using the canonical detached Windows launch path.""" _assert_windows() running_pids = _gateway_pids() if running_pids: @@ -1349,14 +1412,9 @@ def start() -> None: print(" If a UAC prompt opened, approve it, then run: hermes gateway start") return - if task_installed: - code, _out, err = _exec_schtasks(["/Run", "/TN", get_task_name()]) - if code == 0: - _report_gateway_start(f"Scheduled Task {get_task_name()!r}") - return - print(f"⚠ schtasks /Run failed (code {code}): {err.strip()} — falling back to direct spawn") - - # Startup fallback or failed /Run: direct spawn one foreground-detached gateway. + # Manual starts use the same console-less direct spawn path as restart() + # and install --start-now. Scheduled Task / Startup entries are only login + # persistence mechanisms. pid = _spawn_detached() _report_gateway_start(f"direct spawn (PID {pid})") @@ -1397,6 +1455,61 @@ def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool: return False +def _windows_stop_drain_timeout() -> float: + """Return a bounded Windows gateway stop grace period.""" + try: + from hermes_cli.gateway import _get_restart_drain_timeout + + configured = float(_get_restart_drain_timeout() or 30.0) + except Exception: + configured = 30.0 + # Windows CLI stop must not wedge forever. Give the gateway a real + # graceful-drain window, then escalate to the known PID. + return max(1.0, min(configured, 30.0)) + + +def _force_terminate_known_gateway_pids(pids: list[int]) -> int: + """Force-kill known gateway PIDs without a broad process sweep.""" + try: + from gateway.status import _pid_exists, terminate_pid + except ImportError: + return 0 + + own_pid = os.getpid() + killed = 0 + seen: set[int] = set() + for pid in pids: + if pid <= 0 or pid == own_pid or pid in seen: + continue + seen.add(pid) + try: + if not _pid_exists(pid): + continue + terminate_pid(pid, force=True) + killed += 1 + except ProcessLookupError: + continue + except PermissionError: + print(f"⚠ Permission denied to kill PID {pid}") + except OSError as exc: + print(f"Failed to kill PID {pid}: {exc}") + return killed + + +def _collect_gateway_stop_pids(primary_pid: int | None = None) -> list[int]: + """Collect gateway PIDs for the active profile, preserving primary first.""" + pids: list[int] = [] + if primary_pid is not None and primary_pid > 0: + pids.append(primary_pid) + try: + for pid in _gateway_pids(): + if pid > 0 and pid not in pids: + pids.append(pid) + except Exception: + pass + return pids + + def stop() -> None: """Stop the gateway. @@ -1404,11 +1517,10 @@ def stop() -> None: in-flight agents and persist ``resume_pending`` before exit (the gateway's marker-watcher thread picks this up — Windows asyncio can't deliver SIGTERM to the loop, so the marker is our only IPC). - Then escalates: ``schtasks /End`` (kills the scheduled-task tree) - + ``kill_gateway_processes(force=True)`` for any strays. + Then escalates with bounded Windows process termination against the + known gateway PID(s). """ _assert_windows() - from hermes_cli.gateway import kill_gateway_processes, _get_restart_drain_timeout from gateway.status import get_running_pid # Phase 1: ask the running gateway (if any) to drain itself by writing @@ -1416,13 +1528,10 @@ def stop() -> None: # On clean exit, sessions land with resume_pending=True and the next # boot will auto-resume them. pid = get_running_pid() + stop_pids = _collect_gateway_stop_pids(pid) drained = False if pid is not None: - try: - drain_timeout = float(_get_restart_drain_timeout() or 30.0) - except Exception: - drain_timeout = 30.0 - drained = _drain_gateway_pid(pid, drain_timeout) + drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout()) stopped_any = drained if is_task_registered(): @@ -1433,11 +1542,11 @@ def stop() -> None: elif "not running" not in (err or "").lower(): print(f"⚠ schtasks /End returned code {code}: {err.strip()}") - # Phase 3: hard-kill any strays. When drain succeeded this is a no-op; - # when drain timed out this is the escalation that ensures the PID - # actually exits. Use force=True on Windows so taskkill /T /F walks - # the descendant tree (browser helpers, etc.). - killed = kill_gateway_processes(all_profiles=False, force=not drained) + # Phase 3: hard-kill any still-known gateway processes. Avoid the generic + # process sweep here: Windows direct-spawn starts are profile-scoped, and a + # stop command must be bounded even if the scanner or shutdown path is wedged. + stop_pids.extend(pid for pid in _collect_gateway_stop_pids() if pid not in stop_pids) + killed = _force_terminate_known_gateway_pids(stop_pids) if killed: stopped_any = True print(f"✓ Killed {killed} gateway process(es)") @@ -1479,13 +1588,12 @@ def restart() -> None: doesn't produce a running gateway. """ _assert_windows() - from hermes_cli.gateway import kill_gateway_processes stop() if not _wait_for_gateway_absent(timeout_s=30.0): print("⚠ Gateway still present after stop; forcing termination before restart...") - kill_gateway_processes(all_profiles=False, force=True) + _force_terminate_known_gateway_pids(_collect_gateway_stop_pids()) if not _wait_for_gateway_absent(timeout_s=10.0): raise RuntimeError( "Gateway process still detected after force kill; refusing to " diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b88c6a20475..06b5683b874 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -33,6 +33,8 @@ import threading import time import urllib.error import urllib.parse + +from hermes_cli._subprocess_compat import windows_detach_flags, windows_hide_flags import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -1228,12 +1230,17 @@ def _fs_default_cwd() -> str: def _fs_git_branch(cwd: str) -> str: try: + run_kwargs: Dict[str, Any] = { + "capture_output": True, + "text": True, + "timeout": 2, + "check": False, + } + if sys.platform == "win32": + run_kwargs["creationflags"] = windows_hide_flags() result = subprocess.run( ["git", "-C", cwd, "branch", "--show-current"], - capture_output=True, - text=True, - timeout=2, - check=False, + **run_kwargs, ) return result.stdout.strip() if result.returncode == 0 else "" except Exception: @@ -2387,6 +2394,18 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None} +def _dashboard_spawn_executable() -> str: + """Prefer pythonw.exe for detached dashboard actions on Windows.""" + if sys.platform != "win32": + return sys.executable + exe = sys.executable + if exe.lower().endswith("python.exe"): + pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe") + if os.path.isfile(pythonw): + return pythonw + return exe + + def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: """Spawn ``hermes `` detached and record the Popen handle. @@ -2401,7 +2420,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) - cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] + cmd = [_dashboard_spawn_executable(), "-m", "hermes_cli.main", *subcommand] popen_kwargs: Dict[str, Any] = { "cwd": str(PROJECT_ROOT), @@ -2411,10 +2430,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, } if sys.platform == "win32": - popen_kwargs["creationflags"] = ( - subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] - | getattr(subprocess, "DETACHED_PROCESS", 0) - ) + popen_kwargs["creationflags"] = windows_detach_flags() else: popen_kwargs["start_new_session"] = True @@ -12943,6 +12959,45 @@ def _read_bound_port(server: "uvicorn.Server", fallback: int) -> int: return fallback +def _write_dashboard_ready_file(actual_port: int) -> None: + """Optionally publish the dashboard port through an atomic ready file. + + Windows Desktop can launch dashboard backends with ``pythonw.exe`` to avoid + console flashes. That path cannot rely on stdout for the port announcement, + so Electron passes ``HERMES_DESKTOP_READY_FILE`` and waits for this JSON. + Normal CLI/dashboard launches still use the stdout READY line below. + """ + target = os.environ.get("HERMES_DESKTOP_READY_FILE") + if not target: + return + + tmp_name = "" + try: + path = Path(target) + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps({"port": int(actual_port)}, separators=(",", ":")) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=str(path.parent), + prefix=f"{path.name}.", + suffix=".tmp", + delete=False, + ) as fh: + fh.write(payload) + fh.flush() + os.fsync(fh.fileno()) + tmp_name = fh.name + os.replace(tmp_name, path) + except Exception as exc: + if tmp_name: + try: + Path(tmp_name).unlink(missing_ok=True) + except Exception: + pass + _log.warning("Failed to write dashboard ready file %r: %s", target, exc) + + def _maybe_open_browser( host: str, actual_port: int, open_browser: bool, initial_profile: str ) -> None: @@ -13134,6 +13189,7 @@ def start_server( actual_port = _read_bound_port(server, fallback=port) app.state.bound_port = actual_port + _write_dashboard_ready_file(actual_port) print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True) print(f" Hermes Web UI → http://{host}:{actual_port}") _maybe_open_browser(host, actual_port, open_browser, initial_profile) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 9770bcd459f..d4da92c0d79 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -68,6 +68,7 @@ from agent.auxiliary_client import call_llm from hermes_constants import agent_browser_runnable, get_hermes_home from utils import env_int, is_truthy_value from hermes_cli.config import DEFAULT_CONFIG, cfg_get +from hermes_cli._subprocess_compat import windows_hide_flags try: from tools.website_policy import check_website_access @@ -915,8 +916,7 @@ def _run_chrome_fallback_command( # the CLI mid-turn. The agent thread's subprocess spawn # unwound MainThread's prompt_toolkit loop that way — see # diag log: "asyncio.CancelledError → KeyboardInterrupt". - _CREATE_NO_WINDOW = 0x08000000 - _popen_extra["creationflags"] = _CREATE_NO_WINDOW + _popen_extra["creationflags"] = windows_hide_flags() _popen_extra["close_fds"] = True _si = subprocess.STARTUPINFO() _si.dwFlags |= subprocess.STARTF_USESTDHANDLES @@ -2196,8 +2196,7 @@ def _run_browser_command( # See matching block at the other Popen site — CREATE_NO_WINDOW # only, NO CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task # on Python 3.11 Windows → KeyboardInterrupt in CLI MainThread). - _CREATE_NO_WINDOW = 0x08000000 - _popen_extra["creationflags"] = _CREATE_NO_WINDOW + _popen_extra["creationflags"] = windows_hide_flags() _popen_extra["close_fds"] = True _si = subprocess.STARTUPINFO() _si.dwFlags |= subprocess.STARTF_USESTDHANDLES diff --git a/tools/environments/base.py b/tools/environments/base.py index 251bb18f142..dd2dd8bcdaa 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -21,6 +21,7 @@ from pathlib import Path from typing import IO, Callable, Protocol from hermes_constants import get_hermes_home +from hermes_cli._subprocess_compat import windows_hide_flags from tools.interrupt import is_interrupted logger = logging.getLogger(__name__) @@ -141,6 +142,7 @@ def _popen_bash( Backends with special Popen needs (e.g. local's ``preexec_fn``) can bypass this and call :func:`_pipe_stdin` directly. """ + kwargs.setdefault("creationflags", windows_hide_flags()) proc = subprocess.Popen( cmd, stdout=subprocess.PIPE,