mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
Native Windows (with Git for Windows installed) can now run the Hermes CLI and gateway end-to-end without crashing. install.ps1 already existed and the Git Bash terminal backend was already wired up — this PR fills the remaining gaps discovered by auditing every Windows-unsafe primitive (`signal.SIGKILL`, `os.kill(pid, 0)` probes, bare `fcntl`/`termios` imports) and by comparing hermes against how Claude Code, OpenCode, Codex, and Cline handle native Windows. ## What changed ### UTF-8 stdio (new module) - `hermes_cli/stdio.py` — single `configure_windows_stdio()` entry point. Flips the console code page to CP_UTF8 (65001), reconfigures `sys.stdout`/`stderr`/`stdin` to UTF-8, sets `PYTHONIOENCODING` + `PYTHONUTF8` for subprocesses. No-op on non-Windows. Opt out via `HERMES_DISABLE_WINDOWS_UTF8=1`. - Called early in `cli.py::main`, `hermes_cli/main.py::main`, and `gateway/run.py::main` so Unicode banners (box-drawing, geometric symbols, non-Latin chat text) don't `UnicodeEncodeError` on cp1252 consoles. ### Crash sites fixed - `hermes_cli/main.py:7970` (hermes update → stuck gateway sweep): raw `os.kill(pid, _signal.SIGKILL)` → `gateway.status.terminate_pid(pid, force=True)` which routes through `taskkill /T /F` on Windows. - `hermes_cli/profiles.py::_stop_gateway_process`: same fix — also converted SIGTERM path to `terminate_pid()` and widened OSError catch on the intermediate `os.kill(pid, 0)` probe. - `hermes_cli/kanban_db.py:2914, 3041`: raw `signal.SIGKILL` → `getattr(signal, "SIGKILL", signal.SIGTERM)` fallback (matches the pattern already used in `gateway/status.py`). ### OSError widening on `os.kill(pid, 0)` probes Windows raises `OSError` (WinError 87) for a gone PID instead of `ProcessLookupError`. Widened the catch at: - `gateway/run.py:15101` (`--replace` wait-for-exit loop — without this, the loop busy-spins the full 10s every Windows gateway start) - `hermes_cli/gateway.py:228, 460, 940` - `hermes_cli/profiles.py:777` - `tools/process_registry.py::_is_host_pid_alive` - `tools/browser_tool.py:1170, 1206` ### Dashboard PTY graceful degradation `hermes_cli/pty_bridge.py` depends on `fcntl`/`termios`/`ptyprocess`, none of which exist on native Windows. Previously a Windows dashboard would crash on `import hermes_cli.web_server` because of a top-level import. Now: - `hermes_cli/web_server.py` wraps the pty_bridge import in `try/except ImportError` and sets `_PTY_BRIDGE_AVAILABLE=False`. - The `/api/pty` WebSocket handler returns a friendly "use WSL2 for this tab" message instead of exploding. - Every other dashboard feature (sessions, jobs, metrics, config editor) runs natively on Windows. ### Dependency - `pyproject.toml`: add `tzdata>=2023.3; sys_platform == 'win32'` so Python's `zoneinfo` works on Windows (which has no IANA tzdata shipped with the OS). Credits @sprmn24 (PR #13182). ### Docs - README.md: removed "Native Windows is not supported"; added PowerShell one-liner and Git-for-Windows prerequisite note. - `website/docs/getting-started/installation.md`: new Windows section with capability matrix (everything native except the dashboard `/chat` PTY tab, which is WSL2-only). - `website/docs/user-guide/windows-wsl-quickstart.md`: reframed as "WSL2 as an alternative to native" rather than "the only way". - `website/docs/developer-guide/contributing.md`: updated cross-platform guidance with the `signal.SIGKILL` / `OSError` rules we enforce now. - `website/docs/user-guide/features/web-dashboard.md`: acknowledged native Windows works for everything except the embedded PTY pane. ## Why this shape Pulled from a survey of how other agent codebases handle native Windows (Claude Code, OpenCode, Codex, Cline): - All four treat Git Bash as the canonical shell on Windows, same as hermes already does in `tools/environments/local.py::_find_bash()`. - None of them force `SetConsoleOutputCP` — but they don't have to, Node/Rust write UTF-16 to the Win32 console API. Python does not get that for free, so we flip CP_UTF8 via ctypes. - None of them ship PowerShell-as-primary-shell (Claude Code exposes PS as a secondary tool; scope creep for this PR). - All of them use `taskkill /T /F` for force-kill on Windows, which is exactly what `gateway.status.terminate_pid(force=True)` does. ## Non-goals (deliberate scope limits) - No PowerShell-as-a-second-shell tool — worth designing separately. - No terminal routing rewrite (#12317, #15461, #19800 cluster) — that's the hardest design call and needs a separate doc. - No wholesale `open()` → `open(..., encoding="utf-8")` sweep (Tianworld cluster) — will do as follow-up if users hit actual breakage; most modern code already specifies it. ## Validation - 28 new tests in `tests/tools/test_windows_native_support.py` — all platform-mocked, pass on Linux CI. Cover: - `configure_windows_stdio` idempotency, opt-out, env-preservation - `terminate_pid` taskkill routing, failure → OSError, FileNotFoundError fallback - `getattr(signal, "SIGKILL", …)` fallback shape - `_is_host_pid_alive` OSError widening (Windows-gone-PID behavior) - Source-level checks that all entry points call `configure_windows_stdio` - pty_bridge import-guard present in `web_server.py` - README no longer says "not supported" - 12 pre-existing tests in `tests/tools/test_windows_compat.py` still pass. - `tests/hermes_cli/` ran fully (3909 passed, 9 failures — all confirmed pre-existing on main by stash-test). - `tests/gateway/` ran fully (5021 passed, 1 pre-existing failure). - `tests/tools/test_process_registry.py` + `test_browser_*` pass. - Manual smoke: `import hermes_cli.stdio; import gateway.run; import hermes_cli.web_server` — all clean, `_PTY_BRIDGE_AVAILABLE=True` on Linux (as expected). ## Files - New: `hermes_cli/stdio.py`, `tests/tools/test_windows_native_support.py` - Modified: `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, `hermes_cli/profiles.py`, `hermes_cli/gateway.py`, `hermes_cli/kanban_db.py`, `hermes_cli/pty_bridge.py`, `hermes_cli/web_server.py`, `tools/browser_tool.py`, `tools/process_registry.py`, `pyproject.toml`, `README.md`, and 4 docs pages. Credits to everyone whose prior PR work informed these fixes — see the co-author trailers. All of the PRs listed in `~/.hermes/plans/windows-support-prs.md` fixing `os.kill` / `signal.SIGKILL` / UTF-8 stdio / tzdata / README patterns found the same issues; this PR consolidates them. Co-authored-by: Philip D'Souza <9472774+PhilipAD@users.noreply.github.com> Co-authored-by: Arecanon <42595053+ArecaNon@users.noreply.github.com> Co-authored-by: XiaoXiao0221 <263113677+XiaoXiao0221@users.noreply.github.com> Co-authored-by: Lars Hagen <1360677+lars-hagen@users.noreply.github.com> Co-authored-by: Luan Dias <65574834+luandiasrj@users.noreply.github.com> Co-authored-by: Ruzzgar <ruzzgarcn@gmail.com> Co-authored-by: sprmn24 <oncuevtv@gmail.com> Co-authored-by: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Co-authored-by: Prasanna28Devadiga <54196612+Prasanna28Devadiga@users.noreply.github.com>
134 lines
5.2 KiB
Python
134 lines
5.2 KiB
Python
"""Windows-safe stdio configuration.
|
|
|
|
On Windows, Python's ``sys.stdout``/``sys.stderr`` default to the console's
|
|
active code page (often ``cp1252``, sometimes ``cp437``, occasionally ``cp932``
|
|
on Japanese locales, etc.). Hermes's banners, tool output feed, and slash
|
|
command listings all contain Unicode: box-drawing characters (``─┌┐└┘├┤``),
|
|
mathematical and geometric symbols (``◆ ◇ ◎ ▣ ⚔ ⚖ →``), and user-supplied
|
|
text in any language. Printing those to a cp1252 console raises
|
|
``UnicodeEncodeError: 'charmap' codec can't encode character…`` and kills the
|
|
whole CLI before the REPL even opens.
|
|
|
|
The fix is to force UTF-8 on the Python side and also flip the console's
|
|
code page to UTF-8 (65001). Both matter: Python-level only helps when
|
|
Python's stdout is a real TTY; code-page flipping lets subprocesses and
|
|
child Python ``print()`` calls agree on encoding.
|
|
|
|
This module is a no-op on every non-Windows platform, and idempotent.
|
|
Entry points (``cli.py`` ``main``, ``hermes_cli/main.py`` CLI dispatch,
|
|
``gateway/run.py`` startup) call :func:`configure_windows_stdio` exactly
|
|
once early in startup.
|
|
|
|
Patterns cribbed from Claude Code (``src/utils/platform.ts``), OpenCode
|
|
(``packages/opencode/src/pty/index.ts`` env injection), and OpenAI Codex
|
|
(``codex-rs/core/src/unified_exec/process_manager.rs``). None of those
|
|
actually flip the console code page — they rely on their runtime (Node or
|
|
Rust) writing UTF-16 to the Win32 console API and letting the terminal
|
|
sort it out. Python doesn't get that luxury.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
__all__ = ["configure_windows_stdio", "is_windows"]
|
|
|
|
|
|
_CONFIGURED = False
|
|
|
|
|
|
def is_windows() -> bool:
|
|
"""Return True iff running on native Windows (not WSL)."""
|
|
return sys.platform == "win32"
|
|
|
|
|
|
def _flip_console_code_page_to_utf8() -> None:
|
|
"""Set the attached console's input and output code pages to UTF-8.
|
|
|
|
Uses ``SetConsoleCP`` / ``SetConsoleOutputCP`` via ``ctypes``. Failure
|
|
is silent — if there's no attached console (e.g. Hermes is running
|
|
behind a redirected stdout, under a service, or inside a PTY-less CI
|
|
runner) these calls simply return 0 and we move on.
|
|
|
|
CP_UTF8 is 65001.
|
|
"""
|
|
try:
|
|
import ctypes
|
|
|
|
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
# Best-effort; if there's no console attached these just fail silently.
|
|
kernel32.SetConsoleCP(65001)
|
|
kernel32.SetConsoleOutputCP(65001)
|
|
except Exception:
|
|
# ctypes import, missing kernel32, or non-Windows — any failure here
|
|
# is non-fatal. We've still reconfigured Python's own streams below.
|
|
pass
|
|
|
|
|
|
def _reconfigure_stream(stream, *, encoding: str = "utf-8", errors: str = "replace") -> None:
|
|
"""Reconfigure a text stream to UTF-8 in place.
|
|
|
|
Uses ``TextIOWrapper.reconfigure`` (Python 3.7+). If the stream isn't
|
|
a ``TextIOWrapper`` (e.g. it's been redirected to an ``io.StringIO``
|
|
during tests), we skip rather than blow up.
|
|
"""
|
|
try:
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if reconfigure is None:
|
|
return
|
|
reconfigure(encoding=encoding, errors=errors)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def configure_windows_stdio() -> bool:
|
|
"""Force UTF-8 stdio on Windows. No-op elsewhere.
|
|
|
|
Idempotent — safe to call multiple times from different entry points.
|
|
|
|
Returns ``True`` if anything was actually changed, ``False`` on
|
|
non-Windows or on a repeat call.
|
|
|
|
Set ``HERMES_DISABLE_WINDOWS_UTF8=1`` in the environment to opt out
|
|
(for diagnosing encoding-related bugs by forcing the old cp1252 path).
|
|
"""
|
|
global _CONFIGURED
|
|
|
|
if _CONFIGURED:
|
|
return False
|
|
if not is_windows():
|
|
# Mark configured so repeated calls on POSIX are true no-ops.
|
|
_CONFIGURED = True
|
|
return False
|
|
|
|
if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in ("1", "true", "True", "yes"):
|
|
_CONFIGURED = True
|
|
return False
|
|
|
|
# Encourage every child Python process spawned by the agent to also use
|
|
# UTF-8 for its stdio. PYTHONIOENCODING wins over the locale-based
|
|
# default in subprocesses. Don't override an explicit user setting.
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
# PYTHONUTF8 = 1 enables UTF-8 Mode globally for any Python subprocess
|
|
# (PEP 540). Again, don't override an explicit setting.
|
|
os.environ.setdefault("PYTHONUTF8", "1")
|
|
|
|
# Flip the console code page first so that any subprocess that
|
|
# inherits the console (e.g. a launched shell) also sees CP_UTF8.
|
|
_flip_console_code_page_to_utf8()
|
|
|
|
# Reconfigure Python's own stdio wrappers so ``print()`` calls from
|
|
# this process round-trip emoji / box-drawing / non-Latin text.
|
|
# ``errors="replace"`` means a genuinely unencodable byte sequence
|
|
# gets a ``?`` rather than crashing the interpreter — we prefer
|
|
# degraded output over a stack trace.
|
|
_reconfigure_stream(sys.stdout)
|
|
_reconfigure_stream(sys.stderr)
|
|
# stdin is re-configured for completeness; Hermes's interactive
|
|
# input path uses prompt_toolkit which manages its own encoding,
|
|
# but batch/pipe input benefits from UTF-8 decoding on stdin too.
|
|
_reconfigure_stream(sys.stdin)
|
|
|
|
_CONFIGURED = True
|
|
return True
|