Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1565,18 +1565,17 @@ function readVenvHome(venvRoot) {
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

// uv venv launchers can re-exec console python.exe, which allocates conhost /
// Windows Terminal. Use base pythonw directly and provide imports via env.
const baseHome = readVenvHome(venvRoot)
if (baseHome) {
const basePythonw = path.join(baseHome, 'pythonw.exe')
if (fileExists(basePythonw)) return basePythonw
}

const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
if (fileExists(venvPythonw)) return venvPythonw

return venvPythonw
}

Expand Down Expand Up @@ -2797,7 +2796,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
root,
Expand All @@ -2821,7 +2820,7 @@ function createActiveBackend(dashboardArgs) {
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [ACTIVE_HERMES_ROOT],
pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)],
venvRoot: VENV_ROOT
}),
root: ACTIVE_HERMES_ROOT,
Expand Down
7 changes: 7 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3046,4 +3046,11 @@ def _on_done(_f: concurrent.futures.Future) -> None:


if __name__ == "__main__":
# Standalone background scheduler: drop any console a uv pythonw→python
# re-exec auto-allocated. No-op on POSIX / when run in-gateway.
try:
import hermes_bootstrap
hermes_bootstrap.detach_orphan_console()
except Exception:
pass
tick(verbose=True)
7 changes: 7 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18593,6 +18593,13 @@ def restart_signal_handler():

def main():
"""CLI entry point for the gateway."""
# Background daemon: drop any console auto-allocated by a uv pythonw→python
# re-exec so no terminal lingers. No-op on POSIX / when already detached.
try:
hermes_bootstrap.detach_orphan_console()
except Exception:
pass

# Force UTF-8 stdio on Windows — gateway logs and startup banner would
# otherwise UnicodeEncodeError on cp1252 consoles. No-op on POSIX.
try:
Expand Down
4 changes: 3 additions & 1 deletion gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ def terminate_pid(pid: int, *, force: bool = False) -> None:
because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
"""
if force and _IS_WINDOWS:
from hermes_cli import _subprocess_compat

try:
result = subprocess.run(
result = _subprocess_compat.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
Expand Down
58 changes: 58 additions & 0 deletions hermes_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ def apply_windows_utf8_bootstrap() -> bool:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")

# Python's platform.win32_ver()/platform.platform() can shell out to
# ``cmd.exe /c ver`` on Windows. In pythonw-launched background processes
# that still creates a visible terminal handoff on machines where Windows
# Terminal is the default console host. Disable that subprocess path early.
try:
import platform

def _no_subprocess_syscmd_ver(
system: str = "",
release: str = "",
version: str = "",
*_args,
**_kwargs,
) -> tuple[str, str, str]:
return system or "Windows", release, version

platform._syscmd_ver = _no_subprocess_syscmd_ver # type: ignore[attr-defined]
except Exception:
pass

# 2. Reconfigure the current process's stdio to UTF-8. Needed
# because os.environ changes don't retroactively rebind sys.stdout
# — those were bound at interpreter startup based on the console
Expand Down Expand Up @@ -122,6 +142,44 @@ def apply_windows_utf8_bootstrap() -> bool:
return True


def detach_orphan_console() -> bool:
"""Free a console window that was auto-allocated for this process alone.

Background-only entry points (gateway daemon, dashboard backend, cron
runner, TUI/desktop stdio backends) call this explicitly. uv-created venvs
ship a ``Scripts\\pythonw.exe`` redirector that re-execs the *base* console
``python.exe``; that re-exec allocates its own conhost/Windows Terminal
window even though the launcher wanted no console. We drop it so nothing
lingers.

This is NOT wired into the import-time bootstrap on purpose: the discriminator
(``GetConsoleProcessList() == 1``) cannot tell a phantom console apart from a
user who deliberately opened the *interactive* CLI/TUI in its own fresh
console (double-click, Start-menu shortcut, a ConPTY), since both report a
single attached process with a tty. Intent is only knowable from the entry
point — so only known-background mains call this, never the interactive CLI.

A properly detached daemon (``DETACHED_PROCESS``) has no console at all, so
``GetConsoleWindow()`` is NULL and this is a no-op. Returns True iff a console
was actually freed. No-op (returns False) on non-Windows.
"""
if not _IS_WINDOWS:
return False
try:
import ctypes

kernel32 = ctypes.windll.kernel32
if not kernel32.GetConsoleWindow():
return False
buf = (ctypes.c_uint * 4)()
if kernel32.GetConsoleProcessList(buf, 4) == 1:
kernel32.FreeConsole()
return True
except Exception:
pass
return False


def harden_import_path(src_root: str | None = None) -> None:
"""Stop a package in the current directory from shadowing Hermes modules.

Expand Down
41 changes: 41 additions & 0 deletions hermes_cli/_subprocess_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@
from __future__ import annotations

import shutil
import subprocess
import sys
from typing import Sequence

__all__ = [
"IS_WINDOWS",
"resolve_node_command",
"run",
"popen",
"windows_detach_flags",
"windows_detach_flags_without_breakaway",
"windows_hide_flags",
Expand Down Expand Up @@ -201,6 +204,44 @@ def windows_hide_flags() -> int:
return _CREATE_NO_WINDOW


# -----------------------------------------------------------------------------
# The single chokepoint for spawning a process without a console window.
# -----------------------------------------------------------------------------


def _no_window(kwargs: dict) -> dict:
"""OR ``CREATE_NO_WINDOW`` into ``creationflags`` on Windows (no-op on POSIX).

Merges rather than overwrites, so a caller that needs detach semantics can
pass ``creationflags=windows_detach_flags()`` and still go through here —
``CREATE_NO_WINDOW`` is already part of that bundle, so the OR is idempotent.
"""
if IS_WINDOWS:
kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_NO_WINDOW
return kwargs


def run(cmd, **kwargs):
"""``subprocess.run`` that never flashes a console window on Windows.

This is the primitive every Hermes spawn of a *console-subsystem* program
(``taskkill``, ``schtasks``, ``agent-browser``, ``git-bash``, version
probes, …) must use. Routing through one function makes "no visible
terminal" structural instead of a per-call-site rule that gets forgotten —
which is exactly how cron-driven and future spawns leaked windows before.

Python child processes are additionally covered by the ``FreeConsole``
catch-all in :mod:`hermes_bootstrap`, but native exes can't run that, so the
spawn-time flag here is the only thing that helps them.
"""
return subprocess.run(cmd, **_no_window(kwargs))


def popen(cmd, **kwargs):
"""``subprocess.Popen`` counterpart of :func:`run` — see its docstring."""
return subprocess.Popen(cmd, **_no_window(kwargs))


def windows_detach_popen_kwargs() -> dict:
"""Return a dict of Popen kwargs that detach a child on Windows and
fall back to the POSIX equivalent (``start_new_session=True``) on
Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/claw.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ def _detect_openclaw_processes() -> list[str]:

# -- process scan ------------------------------------------------------
if sys.platform == "win32":
from hermes_cli import _subprocess_compat

try:
for exe in ("openclaw.exe", "clawd.exe"):
result = subprocess.run(
result = _subprocess_compat.run(
["tasklist", "/FI", f"IMAGENAME eq {exe}"],
capture_output=True, text=True, timeout=5,
)
Expand All @@ -93,7 +95,7 @@ def _detect_openclaw_processes() -> list[str]:
'Where-Object { $_.CommandLine -match "openclaw|clawd" } | '
'Select-Object -First 1 ProcessId'
)
result = subprocess.run(
result = _subprocess_compat.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True, text=True, timeout=5,
)
Expand Down
8 changes: 6 additions & 2 deletions hermes_cli/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ def _macos_osascript(dest: Path) -> bool:


def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
return subprocess.run(
from hermes_cli import _subprocess_compat

return _subprocess_compat.run(
[exe, "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, text=True, timeout=timeout,
)
Expand Down Expand Up @@ -254,9 +256,11 @@ def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) ->

def _find_powershell() -> str | None:
"""Return the first available PowerShell executable, or None."""
from hermes_cli import _subprocess_compat

for name in ("powershell", "pwsh"):
try:
r = subprocess.run(
r = _subprocess_compat.run(
[name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"],
capture_output=True, text=True, timeout=5,
)
Expand Down
23 changes: 20 additions & 3 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,24 @@ def _matches_gateway_runtime(command: str) -> bool:

try:
if is_windows():
try:
import psutil # type: ignore

for proc in psutil.process_iter(["pid", "cmdline"]):
pid = int(proc.info.get("pid") or 0)
if pid == os.getpid() or pid in exclude_pids:
continue
command = " ".join(proc.info.get("cmdline") or [])
if _matches_gateway_runtime(command) and (
all_profiles or _matches_current_profile(command)
):
_append_unique_pid(pids, pid, exclude_pids)
return _filter_venv_launcher_stubs(pids) if len(pids) > 1 else pids
except Exception:
pass

from hermes_cli import _subprocess_compat

# Prefer wmic when present (fast, stable output format). On
# modern Windows 11 / Win 10 late builds, wmic has been
# removed as part of the WMIC deprecation — fall back to
Expand All @@ -390,7 +408,7 @@ def _matches_gateway_runtime(command: str) -> bool:
result = None
if wmic_path is not None:
try:
result = subprocess.run(
result = _subprocess_compat.run(
[
wmic_path,
"process",
Expand Down Expand Up @@ -421,7 +439,7 @@ def _matches_gateway_runtime(command: str) -> bool:
"}"
)
try:
result = subprocess.run(
result = _subprocess_compat.run(
[powershell, "-NoProfile", "-Command", ps_cmd],
capture_output=True,
text=True,
Expand Down Expand Up @@ -6632,7 +6650,6 @@ def _gateway_command_inner(args):
# path that can be reaped with the old gateway process. If the
# Windows backend raises, intentionally preserve the existing
# generic failure fallback below.
service_configured = gateway_windows.is_installed()
try:
gateway_windows.restart()
return
Expand Down
23 changes: 15 additions & 8 deletions hermes_cli/gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@
from pathlib import Path
from xml.sax.saxutils import escape

from hermes_cli import _subprocess_compat
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.
Expand Down Expand Up @@ -157,7 +157,7 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
if schtasks is None:
return (1, "", "schtasks.exe not found on PATH")
try:
proc = subprocess.run(
proc = _subprocess_compat.run(
[schtasks, *args],
capture_output=True,
text=True,
Expand All @@ -168,10 +168,6 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
encoding=_schtasks_encoding(),
errors="replace",
timeout=_SCHTASKS_TIMEOUT_S,
# 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=windows_hide_flags(),
)
return (proc.returncode, proc.stdout or "", proc.stderr or "")
except subprocess.TimeoutExpired:
Expand Down Expand Up @@ -1605,7 +1601,17 @@ def stop() -> None:
drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout())

stopped_any = drained
if is_task_registered():
has_service_artifact = (
get_task_script_path().exists()
or get_task_script_path().with_suffix(".vbs").exists()
or get_startup_entry_path().exists()
or _legacy_startup_entry_path().exists()
)
if (
has_service_artifact
and os.getenv("HERMES_NONINTERACTIVE") != "1"
and is_task_registered()
):
code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()])
# schtasks returns nonzero when the task isn't currently running — don't treat that as an error.
if code == 0:
Expand Down Expand Up @@ -1673,7 +1679,8 @@ def restart() -> None:

# Give Windows a moment to release the listening port.
time.sleep(1.0)
start()
pid = _spawn_detached()
_report_gateway_start(f"direct spawn (PID {pid})")

if not _wait_for_gateway_ready(timeout_s=15.0):
raise RuntimeError(
Expand Down
Loading
Loading