diff --git a/README.md b/README.md index b8fe21171476..5d44e296afcb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > -> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). +> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. After installation: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 98107dcbe959..1034d3578a12 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7566,20 +7566,32 @@ async def get_models_analytics(days: int = 30): # though uvicorn binds to 127.0.0.1. # --------------------------------------------------------------------------- -# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native -# Windows the import raises; catch and leave PtyBridge=None so the rest of -# the dashboard (sessions, jobs, metrics, config editor) still loads and the -# /api/pty endpoint cleanly refuses with a WSL-suggested message. -try: - from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError - _PTY_BRIDGE_AVAILABLE = True -except ImportError as _pty_import_err: # pragma: no cover - Windows-only path - PtyBridge = None # type: ignore[assignment] - _PTY_BRIDGE_AVAILABLE = False - - class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] - """Stub on platforms where pty_bridge can't be imported.""" - pass +# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows +# uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both +# expose the same public surface — spawn/read/write/resize/close/is_available — +# so the /api/pty WebSocket handler needs no platform guards. +if sys.platform.startswith("win"): + try: + from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError + _PTY_BRIDGE_AVAILABLE = True + except ImportError: # pragma: no cover - pywinpty missing + PtyBridge = None # type: ignore[assignment] + _PTY_BRIDGE_AVAILABLE = False + + class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] + """Stub when win_pty_bridge cannot be imported.""" + pass +else: + try: + from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + _PTY_BRIDGE_AVAILABLE = True + except ImportError: # pragma: no cover - dev env without ptyprocess + PtyBridge = None # type: ignore[assignment] + _PTY_BRIDGE_AVAILABLE = False + + class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] + """Stub on platforms where pty_bridge can't be imported.""" + pass _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 diff --git a/hermes_cli/win_pty_bridge.py b/hermes_cli/win_pty_bridge.py new file mode 100644 index 000000000000..fe8ca1acb04f --- /dev/null +++ b/hermes_cli/win_pty_bridge.py @@ -0,0 +1,179 @@ +"""Windows ConPTY bridge for the `hermes dashboard` chat tab. + +Drop-in counterpart to ``hermes_cli.pty_bridge.PtyBridge`` for native +Windows. Mirrors the exact public surface the ``/api/pty`` WebSocket +handler in ``hermes_cli.web_server`` consumes: ``spawn``, ``read``, +``write``, ``resize``, ``close``, ``is_available``, plus the +``PtyUnavailableError`` type. + +Backed by ``pywinpty`` (already a declared win32 dependency in +pyproject.toml) instead of ``ptyprocess``/``fcntl``/``termios``, none of +which exist on native Windows. The read/write/terminate calls here match +the working winpty usage already shipping in ``tools/process_registry.py``. +""" + +from __future__ import annotations + +import os +import sys +import time +from typing import Optional, Sequence + +try: + from winpty import PtyProcess # type: ignore + _PTY_AVAILABLE = sys.platform.startswith("win") +except ImportError: # pragma: no cover - non-Windows or pywinpty missing + PtyProcess = None # type: ignore + _PTY_AVAILABLE = False + + +__all__ = ["WinPtyBridge", "PtyUnavailableError"] + + +# Same clamp ceiling as the POSIX bridge: a broken winsize probe must never +# reach the resize call. ConPTY tolerates large values better than ioctl, +# but we keep parity to avoid layout surprises. +_MIN_DIMENSION = 1 +_MAX_COLS = 2000 +_MAX_ROWS = 1000 + + +def _clamp(value: int, maximum: int) -> int: + try: + n = int(value) + except (TypeError, ValueError, OverflowError): + return _MIN_DIMENSION + if n < _MIN_DIMENSION: + return _MIN_DIMENSION + if n > maximum: + return maximum + return n + + +class PtyUnavailableError(RuntimeError): + """Raised when a PTY cannot be created on this platform.""" + + +class WinPtyBridge: + """pywinpty-backed bridge with the same interface as ``PtyBridge``. + + ``web_server`` calls :meth:`read` inside ``run_in_executor``, so a + blocking/polling read here never stalls the event loop. ConPTY exposes + no selectable fd, so we poll with a short sleep instead of ``select``. + """ + + def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined] + self._proc = proc + self._closed = False + + # -- lifecycle -------------------------------------------------------- + + @classmethod + def is_available(cls) -> bool: + return bool(_PTY_AVAILABLE) + + @classmethod + def spawn( + cls, + argv: Sequence[str], + *, + cwd: Optional[str] = None, + env: Optional[dict] = None, + cols: int = 80, + rows: int = 24, + ) -> "WinPtyBridge": + if not _PTY_AVAILABLE: + if PtyProcess is None: + raise PtyUnavailableError( + "pywinpty is not installed. Install with: pip install pywinpty" + ) + raise PtyUnavailableError("ConPTY is unavailable on this platform.") + spawn_env = (os.environ.copy() if env is None else dict(env)) + if not spawn_env.get("TERM"): + spawn_env["TERM"] = "xterm-256color" + # pywinpty mirrors ptyprocess: dimensions=(rows, cols). + # This call shape is the one already used in tools/process_registry.py. + proc = PtyProcess.spawn( # type: ignore[union-attr] + list(argv), + cwd=cwd, + env=spawn_env, + dimensions=(rows, cols), + ) + return cls(proc) + + @property + def pid(self) -> int: + return int(self._proc.pid) + + def is_alive(self) -> bool: + if self._closed: + return False + try: + return bool(self._proc.isalive()) + except Exception: + return False + + # -- I/O -------------------------------------------------------------- + + def read(self, timeout: float = 0.2) -> Optional[bytes]: + """Up to 64 KiB of child output. + + Returns bytes, ``b""`` when nothing is available this tick, or + ``None`` once the child has exited (EOF). + """ + if self._closed: + return None + try: + data = self._proc.read(65536) # pywinpty returns str + except EOFError: + return None + except Exception: + return None + if not data: + # No fd to select on; poll politely so the executor thread + # doesn't pin a core while the TUI is idle. + time.sleep(min(timeout, 0.02)) + return b"" + if isinstance(data, bytes): + return data + # NOTE: pywinpty decodes internally, so a multibyte UTF-8 sequence + # can in theory split across reads. xterm.js tolerates the rare + # replacement char; this is the one fidelity tradeoff vs the POSIX + # raw-fd path. + return data.encode("utf-8", errors="replace") + + def write(self, data: bytes) -> None: + if self._closed or not data: + return + try: + # The dashboard sends raw keystroke bytes; pywinpty.write wants text. + self._proc.write(data.decode("utf-8", errors="replace")) + except Exception: + return + + def resize(self, cols: int, rows: int) -> None: + if self._closed: + return + cols = _clamp(cols, _MAX_COLS) + rows = _clamp(rows, _MAX_ROWS) + try: + self._proc.setwinsize(rows, cols) # pywinpty: (rows, cols) + except Exception: + pass + + # -- teardown --------------------------------------------------------- + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._proc.terminate(force=True) + except Exception: + pass + + def __enter__(self) -> "WinPtyBridge": + return self + + def __exit__(self, *_exc) -> None: + self.close()