Windows console window flashing from spawned backend processes
Cluster 53539 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #54417 on 28 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue 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.
The members all concern console windows flashing on Windows when the desktop app or gateway spawns subprocesses (pythonw, ffmpeg, netstat, restart watcher). One also mentions a WebSocket 1006 disconnect tied to a backend restart loop; the cluster does not establish resolution status.
Open issues0
Open PRs0
Fixes on record
-
#53892 fix(windows): hide backend console-flash legs main missed (Electron base-pythonw, ffmpeg, netstat)
files:
apps/desktop/electron/main.cjs,apps/desktop/electron/windows-child-process.test.cjs,plugins/platforms/discord/adapter.py,plugins/platforms/whatsapp/adapter.py,tests/test_windows_subprocess_no_window_flags.py,tools/transcription_tools.py,tools/tts_tool.pyShow the diff
-
apps/desktop/electron/main.cjs@@ -1572,19 +1572,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 - + // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the + // base console ``python.exe``, allocating a conhost/Windows Terminal window + // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly; + // callers put the venv site-packages on PYTHONPATH so imports still resolve. const baseHome = readVenvHome(venvRoot) if (baseHome) { const basePythonw = path.join(baseHome, 'pythonw.exe') if (fileExists(basePythonw)) return basePythonw } - return venvPythonw + return path.join(venvRoot, 'Scripts', 'pythonw.exe') } function toNoConsolePython(pythonPath) { @@ -2847,7 +2845,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, @@ -2871,7 +2869,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, -
apps/desktop/electron/windows-child-process.test.cjs@@ -53,6 +53,22 @@ test('desktop background child processes opt into hidden Windows consoles', () = assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) }) +test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { + const source = readElectronFile('main.cjs') + const body = source.slice( + source.indexOf('function getNoConsoleVenvPython(venvRoot)'), + source.indexOf('function getVenvSitePackagesEntries(venvRoot)') + ) + + // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a + // conhost); the base pythonw must be resolved first so it never runs. + const baseIdx = body.indexOf('basePythonw') + const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'") + assert.notEqual(baseIdx, -1, 'base pythonw resolution missing') + assert.notEqual(shimIdx, -1, 'venv shim fallback missing') + assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim') +}) + test('intentional or interactive desktop child processes stay documented', () => { const source = readElectronFile('main.cjs') -
plugins/platforms/discord/adapter.py@@ -665,6 +665,8 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, f.write(pcm_data) pcm_path = f.name try: + from hermes_cli._subprocess_compat import windows_hide_flags + subprocess.run( [ "ffmpeg", "-y", "-loglevel", "error", @@ -679,6 +681,7 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, check=True, timeout=10, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), ) finally: try: -
plugins/platforms/whatsapp/adapter.py@@ -78,10 +78,13 @@ def _kill_port_process(port: int) -> None: """Kill any process *listening* on the given TCP port (a stale bridge).""" try: if _IS_WINDOWS: + from hermes_cli._subprocess_compat import windows_hide_flags + # Use netstat to find the PID bound to this port, then taskkill result = subprocess.run( ["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True, timeout=5, + creationflags=windows_hide_flags(), ) for line in result.stdout.splitlines(): parts = line.split() @@ -92,6 +95,7 @@ def _kill_port_process(port: int) -> None: subprocess.run( ["taskkill", "/PID", parts[4], "/F"], capture_output=True, timeout=5, + creationflags=windows_hide_flags(), ) except subprocess.SubprocessError: pass -
tests/test_windows_subprocess_no_window_flags.py@@ -261,3 +261,41 @@ def fake_run(cmd, **kwargs): assert skill_preprocessing.run_inline_shell("echo ok", cwd=None, timeout=5) == "ok" assert captured[0][0] == ["bash", "-c", "echo ok"] assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_tts_opus_conversion_hides_ffmpeg_window(monkeypatch, tmp_path): + from tools import tts_tool + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(returncode=0) + + monkeypatch.setattr(tts_tool, "_has_ffmpeg", lambda: True) + monkeypatch.setattr(tts_tool, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(tts_tool.subprocess, "run", fake_run) + + tts_tool._convert_to_opus(str(tmp_path / "v.mp3")) + + assert captured[0][0][0] == "ffmpeg" + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW + + +def test_local_stt_audio_prep_hides_ffmpeg_window(monkeypatch, tmp_path): + from tools import transcription_tools + + captured = [] + + def fake_run(cmd, **kwargs): + captured.append((cmd, kwargs)) + return _Completed(returncode=0) + + monkeypatch.setattr(transcription_tools, "_find_ffmpeg_binary", lambda: "ffmpeg") + monkeypatch.setattr(transcription_tools, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) + monkeypatch.setattr(transcription_tools.subprocess, "run", fake_run) + + transcription_tools._prepare_local_audio(str(tmp_path / "in.m4a"), str(tmp_path)) + + assert captured[0][0][0] == "ffmpeg" + assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW -
tools/transcription_tools.py@@ -37,6 +37,7 @@ from typing import Optional, Dict, Any from urllib.parse import urljoin +from hermes_cli._subprocess_compat import windows_hide_flags from utils import is_truthy_value from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import ( @@ -1187,7 +1188,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], command = [ffmpeg, "-y", "-i", file_path, converted_path] try: - subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) return converted_path, None except subprocess.TimeoutExpired: logger.error("ffmpeg conversion timed out for %s", file_path) @@ -1233,9 +1234,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode. use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) if use_shell: - subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) else: - subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) + subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) txt_files = sorted(Path(output_dir).glob("*.txt")) -
tools/tts_tool.py@@ -52,6 +52,7 @@ from typing import Callable, Dict, Any, Optional from urllib.parse import urljoin +from hermes_cli._subprocess_compat import windows_hide_flags from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) @@ -910,6 +911,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]: "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"], capture_output=True, timeout=30, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), ) if result.returncode != 0: logger.warning("ffmpeg conversion failed with return code %d: %s", @@ -1776,7 +1778,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] ] else: cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL) + result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) if result.returncode != 0: stderr = result.stderr.decode("utf-8", errors="ignore")[:300] raise RuntimeError(f"ffmpeg conversion failed: {stderr}") @@ -1871,7 +1873,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) os.remove(wav_path) else: # No ffmpeg — just rename the WAV to the expected path @@ -2050,7 +2052,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) try: os.remove(wav_path) except OSError: @@ -2116,7 +2118,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) ffmpeg = shutil.which("ffmpeg") if ffmpeg: conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path] - subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL) + subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) os.remove(wav_path) else: # No ffmpeg — rename the WAV to the expected path
-
-
#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: superseded by another PR 1
-
#54342 fix(gateway): use pythonw.exe for restart watcher + add dedup and backoff · superseded by another PR →
#54220,#54236,#53892,#54417
Closed issues · 1
- Closed#53539[Desktop] Command windows keep flashing — WebSocket 1006 disconnect causes backend restart loop
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: 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 Lab