mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(windows): suppress console flashes and harden gateway restarts
This commit is contained in:
parent
c4ba4770eb
commit
e7d2f0b93c
12 changed files with 617 additions and 138 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue