fix(windows): suppress console flashes and harden gateway restarts

This commit is contained in:
Gille 2026-06-24 23:31:52 -06:00 committed by Teknium
parent c4ba4770eb
commit e7d2f0b93c
12 changed files with 617 additions and 138 deletions

View file

@ -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,

View file

@ -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()
}
})

View file

@ -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 }
})

View file

@ -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', () => {

View file

@ -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<Rect | null>(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 (
<div aria-hidden={!visible} style={style}>
{ready && <TerminalTab cwd={cwd} onAddSelectionToChat={onAddSelectionToChat} />}
{terminalTakeover && ready && <TerminalTab cwd={cwd} onAddSelectionToChat={onAddSelectionToChat} />}
</div>
)
}

View file

@ -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()
}

View file

@ -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()

View file

@ -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

View file

@ -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\\<name>.cmd``
failure XML settings, and falls back to a ``%APPDATA%\\...\\Startup\\<name>.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\\<task_name>.cmd``
(or ``<HERMES_HOME>/gateway-service/<task_name>.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=<static>;%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 "

View file

@ -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 <subcommand>`` 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)

View file

@ -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

View file

@ -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,