From cb982ad997c5e04c6b647c4cbb3d1b020ec383fb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:01:59 -0700 Subject: [PATCH] fix(windows): hide console-window flash on backend git/gh/wmic/bash subprocess spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows desktop GUI runs its backend headless via pythonw.exe. Several auxiliary subprocess sites that run inside that windowless backend spawned console-subsystem children (git, gh, wmic, powershell, bash, rg, taskkill) WITHOUT CREATE_NO_WINDOW, so Windows allocated a fresh conhost per call and flashed a black window on screen — sometimes continuously (the dashboard Projects-tree git probe alone fired ~118 spawns in 60s on startup). The terminal tool, cron, browser, code_execution, and gateway-spawn paths already carry windows_hide_flags(); these auxiliary probe/scan/launcher legs were missed. Wire the existing helper into them: - tui_gateway/git_probe.py: run_git (+ encoding=utf-8/errors=replace, fixes the cp950 UnicodeDecodeError on CJK paths from the same site) - agent/coding_context.py: _git (per-turn git status/log/diff) - agent/context_references.py: _run_git + _rg_files (@file/@ref resolution) - hermes_cli/copilot_auth.py: gh auth token probe (auxiliary provider:auto) - hermes_cli/gateway.py: wmic + PowerShell Get-CimInstance PID scan - hermes_cli/main.py: wmic stale-dashboard PID scan - gateway/status.py: taskkill /T /F force-kill windows_hide_flags() returns 0 on POSIX, so every changed call is a no-op on Linux/macOS (verified: real git/rg probes still work; Windows-simulated calls all pass creationflags=CREATE_NO_WINDOW). Scoped to the windowless-backend paths that cause the reported flashing. The Electron updater-handoff leg (main.cjs windowsHide:false) and the interactive-CLI banner probes (cli.py) are intentionally NOT touched here — the former needs a Windows-tested change of its own, the latter runs in a visible console anyway. Tracking: #54220 Refs: #53178 #53631 #53781 #53957 #49602 #52982 #53424 #53053 #53016 --- agent/coding_context.py | 4 ++++ agent/context_references.py | 5 +++++ agent/shell_hooks.py | 4 ++++ agent/skill_preprocessing.py | 4 ++++ gateway/status.py | 6 ++++++ hermes_cli/copilot_auth.py | 4 ++++ hermes_cli/gateway.py | 8 ++++++++ hermes_cli/main.py | 6 ++++++ tests/gateway/test_status.py | 12 +++++++++--- tui_gateway/git_probe.py | 6 ++++++ 10 files changed, 56 insertions(+), 3 deletions(-) diff --git a/agent/coding_context.py b/agent/coding_context.py index 78229bc4f55..8fb51a0b04d 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -60,6 +60,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Optional +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger("hermes.coding_context") CODING_TOOLSET = "coding" @@ -647,12 +649,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: def _git(cwd: Path, *args: str) -> str: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: out = subprocess.run( ["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=_GIT_TIMEOUT, + **_popen_kwargs, ) except (OSError, subprocess.SubprocessError): return "" diff --git a/agent/context_references.py b/agent/context_references.py index 6307033d270..fad1ff00159 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Awaitable, Callable from agent.model_metadata import estimate_tokens_rough +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags _QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')' REFERENCE_PATTERN = re.compile( @@ -290,6 +291,7 @@ def _expand_git_reference( args: list[str], label: str, ) -> tuple[str | None, str | None]: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["git", *args], @@ -298,6 +300,7 @@ def _expand_git_reference( text=True, timeout=30, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except subprocess.TimeoutExpired: return f"{ref.raw}: git command timed out (30s)", None @@ -483,6 +486,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]: def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["rg", "--files", str(path.relative_to(cwd))], @@ -491,6 +495,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: text=True, timeout=10, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except (FileNotFoundError, OSError, subprocess.TimeoutExpired): return None diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 97ba3862120..a48bab42bb8 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -122,6 +122,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + try: import fcntl # POSIX only; Windows falls back to best-effort without flock. except ImportError: # pragma: no cover @@ -441,6 +443,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: return result t0 = time.monotonic() + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: proc = subprocess.run( argv, @@ -449,6 +452,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: timeout=spec.timeout, text=True, shell=False, + **_popen_kwargs, ) except subprocess.TimeoutExpired: result["timed_out"] = True diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index a7f526b25e7..bd0386d5805 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -5,6 +5,8 @@ import re import subprocess from pathlib import Path +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger(__name__) # Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md. @@ -66,6 +68,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: Failures return a short ``[inline-shell error: ...]`` marker instead of raising, so one bad snippet can't wreck the whole skill message. """ + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: completed = subprocess.run( ["bash", "-c", command], @@ -75,6 +78,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: timeout=max(1, int(timeout)), check=False, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) except subprocess.TimeoutExpired: return f"[inline-shell timeout after {timeout}s: {command}]" diff --git a/gateway/status.py b/gateway/status.py index 81e5be95f4d..80c0f8286f0 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -81,12 +81,18 @@ 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: + # CREATE_NO_WINDOW: terminate_pid runs from the windowless pythonw.exe + # gateway/desktop backend, so a bare taskkill spawn would flash a + # conhost window on every force-kill. + from hermes_cli._subprocess_compat import windows_hide_flags + try: result = subprocess.run( ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, timeout=10, + creationflags=windows_hide_flags(), ) except FileNotFoundError: os.kill(pid, signal.SIGTERM) diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index e6f63a1557c..dd4643d2425 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -27,6 +27,8 @@ import time from pathlib import Path from typing import Optional +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + logger = logging.getLogger(__name__) # OAuth device code flow constants (same client ID as opencode/Copilot CLI) @@ -130,6 +132,7 @@ def _try_gh_cli_token() -> Optional[str]: clean_env = {k: v for k, v in os.environ.items() if k not in {"GITHUB_TOKEN", "GH_TOKEN"}} + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} for gh_path in _gh_cli_candidates(): cmd = [gh_path, "auth", "token"] if hostname: @@ -141,6 +144,7 @@ def _try_gh_cli_token() -> Optional[str]: text=True, timeout=5, env=clean_env, + **_popen_kwargs, ) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 28ef9cfb642..2624b43253d 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -384,6 +384,12 @@ def _scan_gateway_pids( # removed as part of the WMIC deprecation — fall back to # PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError # on missing wmic) trips the fallback. + # Hide the console window: this scan runs inside the windowless + # pythonw.exe gateway/desktop backend, so a bare wmic/powershell + # spawn would flash a conhost window on every watchdog probe. + from hermes_cli._subprocess_compat import windows_hide_flags + + _no_window = {"creationflags": windows_hide_flags()} wmic_path = shutil.which("wmic") used_fallback = False result = None @@ -402,6 +408,7 @@ def _scan_gateway_pids( encoding="utf-8", errors="ignore", timeout=10, + **_no_window, ) except (OSError, subprocess.TimeoutExpired): result = None @@ -427,6 +434,7 @@ def _scan_gateway_pids( encoding="utf-8", errors="ignore", timeout=15, + **_no_window, ) except (OSError, subprocess.TimeoutExpired): return [] diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c97705da11e..e6445afd766 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5825,6 +5825,11 @@ def _find_stale_dashboard_pids( # here is errors="ignore": it prevents a reader-thread # UnicodeDecodeError from leaving result.stdout=None and turning # the later .split() into an AttributeError (#17049). + # CREATE_NO_WINDOW hides the conhost flash: this scan can run from + # the windowless pythonw.exe desktop/gateway backend during an + # update, where a bare wmic spawn would pop a console window. + from hermes_cli._subprocess_compat import windows_hide_flags + result = subprocess.run( ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], capture_output=True, @@ -5832,6 +5837,7 @@ def _find_stale_dashboard_pids( timeout=10, encoding="utf-8", errors="ignore", + creationflags=windows_hide_flags(), ) if result.returncode != 0 or result.stdout is None: return [] diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 3f88ff1c07c..7301656f6bd 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -621,16 +621,22 @@ class TestTerminatePid: calls = [] monkeypatch.setattr(status, "_IS_WINDOWS", True) - def fake_run(cmd, capture_output=False, text=False, timeout=None): - calls.append((cmd, capture_output, text, timeout)) + def fake_run(cmd, capture_output=False, text=False, timeout=None, creationflags=0): + calls.append((cmd, capture_output, text, timeout, creationflags)) return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(status.subprocess, "run", fake_run) status.terminate_pid(123, force=True) + # taskkill is spawned with the no-window flag so the windowless + # pythonw.exe backend doesn't flash a conhost window on force-kill. + # windows_hide_flags() is 0 on the POSIX test host (a valid no-op + # creationflags value); on real Windows it is CREATE_NO_WINDOW. + from hermes_cli._subprocess_compat import windows_hide_flags + assert calls == [ - (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10) + (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10, windows_hide_flags()) ] def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch): diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py index 01b7998ad14..72582ebf5dc 100644 --- a/tui_gateway/git_probe.py +++ b/tui_gateway/git_probe.py @@ -31,6 +31,8 @@ import time from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags + _GIT_TIMEOUT = 1.5 _WARM_WORKERS = 8 @@ -45,14 +47,18 @@ def run_git(cwd: str, *args: str) -> str: """``git -C `` → stripped stdout, or ``""`` on any failure.""" if not cwd: return "" + _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} try: result = subprocess.run( ["git", "-C", cwd, *args], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=_GIT_TIMEOUT, check=False, stdin=subprocess.DEVNULL, + **_popen_kwargs, ) return result.stdout.strip() if result.returncode == 0 else "" except Exception: