Files
hermes-agent/hermes_cli/win_pty_bridge.py
T
Teknium f0a451f1da refactor(web_server helpers): compact gateway/messaging/cron/oauth helpers and CLI utils (-21% LOC, zero behavior change)
- web_server_cron: one _cron_store_scope ctx manager replaces 3 copies of the
  set_hermes_home_override + use_cron_store + reset pattern
- web_server_oauth: _token_status helper for the 2 credential-status dict builders,
  logged-out sentinel, catalog table packed one card per 2-3 lines (key order kept)
- web_server_messaging: telegram request collapsed to shared detail strings, catalog
  loop unified, env-prefix aliases lifted to a module table, catalog tuples single-line
- web_server_gateway: mode ladder -> dict lookup, owned-platforms comprehension,
  action log table derived; docstrings compacted (every WHY kept)
- windows_ssh_runtime: _read_shared/_write_new/_check/_sid_str helpers, dispatch()
  if-chain -> _OPERATIONS table (arity-checked), _win32 returns a namespace
- worktree_gc/webhook/xai_retirement/write_approval_commands/win_pty_bridge/
  worktree_cmd: dead _require_webhook_enabled/_repo_root inlined, _run helper,
  duplicated except branches merged, docstrings compacted

Parity: route table identical, webhook/worktree --help byte-identical, golden
corpus of 27 pure-function outputs identical vs base 113f04616b.
2026-09-02 21:54:36 -07:00

131 lines
4.2 KiB
Python

"""Windows ConPTY bridge for the `hermes dashboard` chat tab."""
from __future__ import annotations
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 so a broken winsize probe never reaches the resize call.
_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
return max(_MIN_DIMENSION, min(n, maximum))
class PtyUnavailableError(RuntimeError):
"""Raised when a PTY cannot be created on this platform."""
class WinPtyBridge:
"""pywinpty-backed bridge with the same interface as ``PtyBridge``. ``read`` runs inside
``run_in_executor``; ConPTY has no selectable fd, so it polls with a short sleep."""
def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined]
self._proc = proc
self._closed = False
@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.")
# See pty_bridge.py: exact-preservation factory for the env=None fallback.
from tools.environments.local import build_subprocess_env
spawn_env = (
build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)
if env is None else dict(env))
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"
# pywinpty mirrors ptyprocess: dimensions=(rows, cols).
proc = PtyProcess.spawn(list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols)) # type: ignore[union-attr]
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
def read(self, timeout: float = 0.2) -> Optional[bytes]:
"""Up to 64 KiB of child output."""
if self._closed:
return None
try:
data = self._proc.read(65536) # pywinpty returns str
except Exception:
return None
if not data:
# No fd to select on; sleep so the executor thread doesn't pin a core while idle.
time.sleep(min(timeout, 0.02))
return b""
if isinstance(data, bytes):
return data
# pywinpty decodes internally, so a multibyte UTF-8 sequence can split across reads;
# xterm.js tolerates the rare replacement char (the one fidelity tradeoff vs POSIX).
return data.encode("utf-8", errors="replace")
def write(self, data: bytes) -> None:
if self._closed or not data:
return
try:
self._proc.write(data.decode("utf-8", errors="replace")) # pywinpty wants text
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
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()