mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 12:13:05 +00:00
fix(windows): hide console-window flash on backend git/gh/wmic/bash subprocess spawns
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
This commit is contained in:
parent
f25f235722
commit
cb982ad997
10 changed files with 56 additions and 3 deletions
|
|
@ -60,6 +60,8 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.coding_context")
|
logger = logging.getLogger("hermes.coding_context")
|
||||||
|
|
||||||
CODING_TOOLSET = "coding"
|
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:
|
def _git(cwd: Path, *args: str) -> str:
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["git", "-C", str(cwd), *args],
|
["git", "-C", str(cwd), *args],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=_GIT_TIMEOUT,
|
timeout=_GIT_TIMEOUT,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from pathlib import Path
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from agent.model_metadata import estimate_tokens_rough
|
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]+\')'
|
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
|
||||||
REFERENCE_PATTERN = re.compile(
|
REFERENCE_PATTERN = re.compile(
|
||||||
|
|
@ -290,6 +291,7 @@ def _expand_git_reference(
|
||||||
args: list[str],
|
args: list[str],
|
||||||
label: str,
|
label: str,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", *args],
|
["git", *args],
|
||||||
|
|
@ -298,6 +300,7 @@ def _expand_git_reference(
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"{ref.raw}: git command timed out (30s)", None
|
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:
|
def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["rg", "--files", str(path.relative_to(cwd))],
|
["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,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import fcntl # POSIX only; Windows falls back to best-effort without flock.
|
import fcntl # POSIX only; Windows falls back to best-effort without flock.
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
|
|
@ -441,6 +443,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
argv,
|
argv,
|
||||||
|
|
@ -449,6 +452,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
|
||||||
timeout=spec.timeout,
|
timeout=spec.timeout,
|
||||||
text=True,
|
text=True,
|
||||||
shell=False,
|
shell=False,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
result["timed_out"] = True
|
result["timed_out"] = True
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md.
|
# 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
|
Failures return a short ``[inline-shell error: ...]`` marker instead of
|
||||||
raising, so one bad snippet can't wreck the whole skill message.
|
raising, so one bad snippet can't wreck the whole skill message.
|
||||||
"""
|
"""
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
["bash", "-c", command],
|
["bash", "-c", command],
|
||||||
|
|
@ -75,6 +78,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||||
timeout=max(1, int(timeout)),
|
timeout=max(1, int(timeout)),
|
||||||
check=False,
|
check=False,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
|
||||||
"""
|
"""
|
||||||
if force and _IS_WINDOWS:
|
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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
|
# 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()
|
clean_env = {k: v for k, v in os.environ.items()
|
||||||
if k not in {"GITHUB_TOKEN", "GH_TOKEN"}}
|
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():
|
for gh_path in _gh_cli_candidates():
|
||||||
cmd = [gh_path, "auth", "token"]
|
cmd = [gh_path, "auth", "token"]
|
||||||
if hostname:
|
if hostname:
|
||||||
|
|
@ -141,6 +144,7 @@ def _try_gh_cli_token() -> Optional[str]:
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
env=clean_env,
|
env=clean_env,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||||
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
|
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,12 @@ def _scan_gateway_pids(
|
||||||
# removed as part of the WMIC deprecation — fall back to
|
# removed as part of the WMIC deprecation — fall back to
|
||||||
# PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError
|
# PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError
|
||||||
# on missing wmic) trips the fallback.
|
# 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")
|
wmic_path = shutil.which("wmic")
|
||||||
used_fallback = False
|
used_fallback = False
|
||||||
result = None
|
result = None
|
||||||
|
|
@ -402,6 +408,7 @@ def _scan_gateway_pids(
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="ignore",
|
errors="ignore",
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
**_no_window,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
result = None
|
result = None
|
||||||
|
|
@ -427,6 +434,7 @@ def _scan_gateway_pids(
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="ignore",
|
errors="ignore",
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
**_no_window,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -5825,6 +5825,11 @@ def _find_stale_dashboard_pids(
|
||||||
# here is errors="ignore": it prevents a reader-thread
|
# here is errors="ignore": it prevents a reader-thread
|
||||||
# UnicodeDecodeError from leaving result.stdout=None and turning
|
# UnicodeDecodeError from leaving result.stdout=None and turning
|
||||||
# the later .split() into an AttributeError (#17049).
|
# 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(
|
result = subprocess.run(
|
||||||
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
|
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|
@ -5832,6 +5837,7 @@ def _find_stale_dashboard_pids(
|
||||||
timeout=10,
|
timeout=10,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="ignore",
|
errors="ignore",
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if result.returncode != 0 or result.stdout is None:
|
if result.returncode != 0 or result.stdout is None:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -621,16 +621,22 @@ class TestTerminatePid:
|
||||||
calls = []
|
calls = []
|
||||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||||
|
|
||||||
def fake_run(cmd, capture_output=False, text=False, timeout=None):
|
def fake_run(cmd, capture_output=False, text=False, timeout=None, creationflags=0):
|
||||||
calls.append((cmd, capture_output, text, timeout))
|
calls.append((cmd, capture_output, text, timeout, creationflags))
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
||||||
|
|
||||||
status.terminate_pid(123, force=True)
|
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 == [
|
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):
|
def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||||
|
|
||||||
_GIT_TIMEOUT = 1.5
|
_GIT_TIMEOUT = 1.5
|
||||||
_WARM_WORKERS = 8
|
_WARM_WORKERS = 8
|
||||||
|
|
||||||
|
|
@ -45,14 +47,18 @@ def run_git(cwd: str, *args: str) -> str:
|
||||||
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure."""
|
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure."""
|
||||||
if not cwd:
|
if not cwd:
|
||||||
return ""
|
return ""
|
||||||
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "-C", cwd, *args],
|
["git", "-C", cwd, *args],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=_GIT_TIMEOUT,
|
timeout=_GIT_TIMEOUT,
|
||||||
check=False,
|
check=False,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
|
**_popen_kwargs,
|
||||||
)
|
)
|
||||||
return result.stdout.strip() if result.returncode == 0 else ""
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue