Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
40 changes: 26 additions & 14 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions hermes_cli/win_pty_bridge.py
Original file line number Diff line number Diff line change
@@ -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()