Suppress unwanted terminal popups on Windows startup
NousResearch/hermes-agent · cluster 54364
Data as of
Nothing open on GitHub. Fix merged in #54417. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #54417. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address Windows Desktop startup behavior that eagerly spawns agent-browser processes, causing visible console or terminal window flashes. Fixes cover multiple spawn code paths to avoid these background probes.
Open issues0
Open PRs0
Fixes on record
-
#54417 fix(windows): cover remaining console-flash spawn legs
files:
gateway/run.py,gateway/status.py,hermes_cli/gateway.py,hermes_constants.py,tests/gateway/test_restart_drain.py,tests/gateway/test_status.py,tests/test_hermes_constants.py,tests/test_windows_subprocess_no_window_flags.py,tests/tools/test_browser_chromium_check.py,tests/tools/test_browser_homebrew_paths.py,tools/browser_tool.py,tui_gateway/server.pyShow the diff
-
gateway/run.py@@ -5501,6 +5501,19 @@ def _alive(p): # run as a self-restart loop guard and the gateway stays stopped. watcher_env.pop("_HERMES_GATEWAY", None) project_root = Path(__file__).resolve().parent.parent + watcher_python = sys.executable + try: + # Prefer a real GUI-subsystem interpreter for the watcher + # itself. With uv venvs, ``python.exe`` can re-exec the base + # console interpreter and flash even when the Popen carries + # CREATE_NO_WINDOW; pythonw.exe avoids console allocation. + from hermes_cli.gateway_windows import _resolve_detached_python + + watcher_python, _watcher_venv_dir, _watcher_site_packages = ( + _resolve_detached_python(sys.executable) + ) + except Exception: + watcher_python = sys.executable venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") site_packages = venv_dir / "Lib" / "site-packages" if site_packages.exists(): @@ -5510,7 +5523,7 @@ def _alive(p): pythonpath.append(watcher_env["PYTHONPATH"]) watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) subprocess.Popen( - [sys.executable, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], + [watcher_python, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=watcher_env, -
gateway/status.py@@ -171,17 +171,18 @@ def _read_process_cmdline(pid: int) -> Optional[str]: if raw: return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() - try: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "command="], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - except (OSError, subprocess.TimeoutExpired): - pass + if not _IS_WINDOWS: + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass # Windows fallback: psutil (already used by _pid_exists) try: -
hermes_cli/gateway.py@@ -180,7 +180,11 @@ def _get_parent_pid(pid: int) -> int | None: pass except Exception: return None - # Fallback: shell out to ps (POSIX only — bare ``ps`` doesn't exist on Windows). + # Fallback: shell out to ps (POSIX only). Git Bash installs ``ps.exe`` on + # Windows; running it from the windowless desktop/gateway backend flashes a + # console, and psutil above is the authoritative Windows path anyway. + if is_windows(): + return None if not shutil.which("ps"): return None try: -
hermes_constants.py@@ -317,11 +317,14 @@ def node_tool_runnable(path: str | None) -> bool: import subprocess try: + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False @@ -562,11 +565,14 @@ def agent_browser_runnable(path: str | None) -> bool: import subprocess try: + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), + creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False -
tests/gateway/test_restart_drain.py@@ -353,6 +353,49 @@ def fake_popen(cmd, **kwargs): assert kwargs["stderr"] is subprocess.DEVNULL [redacted:email] +async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tmp_path): + runner, _adapter = make_restart_runner() + popen_calls = [] + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run.sys, "executable", r"C:\venv\Scripts\python.exe") + monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"]) + monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + + import hermes_cli._subprocess_compat as subprocess_compat + import hermes_cli.gateway_windows as gateway_windows + + monkeypatch.setattr( + gateway_windows, + "_resolve_detached_python", + lambda _python: (r"C:\Python311\pythonw.exe", venv_dir, [str(site_packages)]), + ) + monkeypatch.setattr( + subprocess_compat, + "windows_detach_popen_kwargs", + lambda: {"creationflags": 0x08000008}, + ) + + def fake_popen(cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + await runner._launch_detached_restart_command() + + assert len(popen_calls) == 1 + cmd, kwargs = popen_calls[0] + assert cmd[0] == r"C:\Python311\pythonw.exe" + assert cmd[-3:] == ["hermes", "gateway", "restart"] + assert kwargs["creationflags"] == 0x08000008 + + # ── Shutdown notification tests ────────────────────────────────────── -
tests/gateway/test_status.py@@ -2,6 +2,7 @@ import json import os +import sys from pathlib import Path from types import SimpleNamespace @@ -1351,6 +1352,7 @@ class TestReadProcessCmdlinePsFallback: def test_ps_fallback_when_proc_unavailable(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="/usr/libexec/bluetoothuserd\n"), @@ -1360,6 +1362,7 @@ def test_ps_fallback_when_proc_unavailable(self, monkeypatch): def test_ps_fallback_returns_none_on_failure(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=1, stdout=""), @@ -1381,6 +1384,7 @@ def fake_read_bytes(self): def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch): monkeypatch.setattr(status.Path, "read_bytes", lambda self: b"") + monkeypatch.setattr(status, "_IS_WINDOWS", False) monkeypatch.setattr( status.subprocess, "run", lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="python hermes_cli/main.py gateway run\n"), @@ -1388,6 +1392,34 @@ def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch): result = status._read_process_cmdline(12345) assert "hermes_cli/main.py" in result + def test_windows_skips_ps_fallback_and_uses_psutil(self, monkeypatch): + monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr(status, "_IS_WINDOWS", True) + ps_calls = [] + monkeypatch.setattr( + status.subprocess, + "run", + lambda args, **kwargs: ps_calls.append((args, kwargs)) or SimpleNamespace(returncode=0, stdout="ps should not run\n"), + ) + + class _Proc: + def __init__(self, pid): + self.pid = pid + + def cmdline(self): + return ["pythonw.exe", "-m", "hermes_cli.main", "gateway", "run"] + + monkeypatch.setitem( + sys.modules, + "psutil", + SimpleNamespace(Process=_Proc), + ) + + result = status._read_process_cmdline(12345) + + assert result == "pythonw.exe -m hermes_cli.main gateway run" + assert ps_calls == [] + class TestCorruptStatusFiles: """A status / pid file holding non-UTF-8 (binary) bytes must read as -
tests/test_hermes_constants.py@@ -2,6 +2,7 @@ import os from pathlib import Path +from types import SimpleNamespace import pytest @@ -611,6 +612,43 @@ def test_npx_fallback_form_accepted(self): assert agent_browser_runnable("npx agent-browser") is True assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True + def test_version_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch): + good = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho hi\n") + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0) + + import hermes_cli._subprocess_compat as subprocess_compat + import subprocess as subprocess_mod + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000) + monkeypatch.setattr(subprocess_mod, "run", fake_run) + + assert agent_browser_runnable(str(good)) is True + assert captured[0][0] == [str(good), "--version"] + assert captured[0][1]["creationflags"] == 0x08000000 + + + def test_node_tool_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch): + good = self._stub(tmp_path, "node", "#!/bin/sh\necho v22\n") + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return SimpleNamespace(returncode=0) + + import hermes_cli._subprocess_compat as subprocess_compat + import subprocess as subprocess_mod + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000) + monkeypatch.setattr(subprocess_mod, "run", fake_run) + + assert node_tool_runnable(str(good)) is True + assert captured[0][0] == [str(good), "--version"] + assert captured[0][1]["creationflags"] == 0x08000000 + class TestGetHermesDir: """Tests for ``get_hermes_dir(new_subpath, old_name)``. -
tests/test_windows_subprocess_no_window_flags.py@@ -299,3 +299,29 @@ def fake_run(cmd, **kwargs): assert captured[0][0][0] == "ffmpeg" assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + +def test_tui_slash_worker_hides_python_window(monkeypatch): + from tui_gateway import server + + captured = [] + + class _Proc: + stdin = SimpleNamespace() + stdout = [] + stderr = [] + + def fake_popen(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Proc() + + monkeypatch.setattr(server.subprocess, "Popen", fake_popen) + monkeypatch.setattr(server.threading, "Thread", lambda *a, **k: SimpleNamespace(start=lambda: None)) + + import hermes_cli._subprocess_compat as subprocess_compat + + monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + + server._SlashWorker("session-key", "model-x") + + assert captured[0][0][:3] == [server.sys.executable, "-m", "tui_gateway.slash_worker"] + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW -
tests/tools/test_browser_chromium_check.py@@ -76,7 +76,7 @@ class TestCheckBrowserRequirementsChromium: def test_local_mode_with_chromium_returns_true(self, monkeypatch, tmp_path): monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") + monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser") monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None) monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) @@ -93,7 +93,7 @@ def provider_name(self): return "browserbase" monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") + monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser") monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) monkeypatch.setattr(bt, "_get_cloud_provider", lambda: FakeProvider()) # Point chromium search at an empty dir — should not matter for cloud. @@ -102,6 +102,23 @@ def provider_name(self): assert bt.check_browser_requirements() is True + def test_startup_check_uses_lightweight_agent_browser_lookup(self, monkeypatch, tmp_path): + seen = [] + + def fake_find_agent_browser(**kwargs): + seen.append(kwargs) + return "/usr/local/bin/agent-browser" + + monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(bt, "_find_agent_browser", fake_find_agent_browser) + monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None) + monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) + (tmp_path / "chromium-1208").mkdir() + + assert bt.check_browser_requirements() is True + assert seen == [{"validate": False}] + def test_camofox_mode_does_not_require_chromium(self, monkeypatch, tmp_path): monkeypatch.setattr(bt, "_is_camofox_mode", lambda: True) # Even with no chromium on disk, camofox drives its own backend. -
tests/tools/test_browser_homebrew_paths.py@@ -222,7 +222,7 @@ def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monke monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False) monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) - monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser") assert check_browser_requirements() is False @@ -231,7 +231,7 @@ class TestRunBrowserCommandTermuxFallback: def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch): monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") - monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser") monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) result = _run_browser_command("task-1", "navigate", ["https://example.com"]) -
tools/browser_tool.py@@ -2002,7 +2002,15 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]: -def _find_agent_browser() -> str: +def _agent_browser_candidate_present(path: str | None) -> bool: + if not path: + return False + if " " in path and path.split()[0].endswith("npx"): + return True + return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK)) + + +def _find_agent_browser(*, validate: bool = True) -> str: """ Find the agent-browser CLI executable. @@ -2041,7 +2049,11 @@ def _find_agent_browser() -> str: # Check if it's in PATH (global install) which_result = shutil.which("agent-browser") - if which_result and agent_browser_runnable(which_result): + if which_result and ( + agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result) + ): + if not validate: + return which_result _cached_agent_browser = which_result _agent_browser_resolved = True return which_result @@ -2051,7 +2063,11 @@ def _find_agent_browser() -> str: extended_path = _merge_browser_path("") if extended_path: which_result = shutil.which("agent-browser", path=extended_path) - if which_result and agent_browser_runnable(which_result): + if which_result and ( + agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result) + ): + if not validate: + return which_result _cached_agent_browser = which_result _agent_browser_resolved = True return which_result @@ -2068,7 +2084,11 @@ def _find_agent_browser() -> str: local_bin_dir = repo_root / "node_modules" / ".bin" if local_bin_dir.is_dir(): local_which = shutil.which("agent-browser", path=str(local_bin_dir)) - if local_which and agent_browser_runnable(local_which): + if local_which and ( + agent_browser_runnable(local_which) if validate else _agent_browser_candidate_present(local_which) + ): + if not validate: + return local_which _cached_agent_browser = local_which _agent_browser_resolved = True return _cached_agent_browser @@ -2078,10 +2098,15 @@ def _find_agent_browser() -> str: if not npx_path and extended_path: npx_path = shutil.which("npx", path=extended_path) if npx_path: + if not validate: + return "npx agent-browser" _cached_agent_browser = "npx agent-browser" _agent_browser_resolved = True return _cached_agent_browser + if not validate: + raise FileNotFoundError("agent-browser CLI not found") + # Nothing found — try lazy installation before giving up. try: from hermes_cli.dep_ensure import ensure_dependency @@ -4206,8 +4231,12 @@ def check_browser_requirements() -> bool: return True # The agent-browser CLI is required for local launch and cloud-provider flows. + # Tool-schema assembly runs during Desktop startup; do not execute + # ``agent-browser --version`` here, because Windows .cmd shims route through + # cmd.exe and can flash a console before the user invokes any browser tool. + # Actual browser execution paths still validate the candidate before use. try: - browser_cmd = _find_agent_browser() + browser_cmd = _find_agent_browser(validate=False) except FileNotFoundError: return False -
tui_gateway/server.py@@ -278,6 +278,8 @@ def __init__(self, session_key: str, model: str): argv += ["--model", model] self._closed = False + from hermes_cli._subprocess_compat import windows_hide_flags + self.proc = subprocess.Popen( argv, stdin=subprocess.PIPE, @@ -289,6 +291,7 @@ def __init__(self, session_key: str, model: str): # slash_worker runs the Hermes agent → needs provider credentials. # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment] + creationflags=windows_hide_flags(), ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start()
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#54366 fix(browser): avoid eager agent-browser probe · done elsewhere →
#54417,#54220
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.39: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #54417
- Closed issues
- 2
- Attempts
- 1 PR closed without merging
- Fix link checked
- 28 Jun 2026