Files
hermes-agent/tools/pty_query_responder.py
Teknium 7f61ae589f Port from openai/codex#41436: answer blocking terminal queries in background PTY sessions
Programs run via terminal(background=true, pty=true) can block forever when
they probe their terminal — device-status (ESC[5n), window-size (ESC[18t),
cursor-position (ESC[6n), or DEC private-mode (ESC[?N$p) queries — because
nothing on the PTY master side answers, and the raw query bytes leak into
captured output.

- tools/pty_query_responder.py: incremental byte scanner that strips the
  handled queries from PTY output (chunk splits included) and produces
  bounded replies; everything else passes through untouched.
- tools/process_registry.py: wire the responder into _pty_reader_loop
  (POSIX only — ConPTY answers its own queries); flush partial escape
  tails at end-of-stream.
- tests mirror the codex fixtures plus a live-PTY E2E where a subprocess
  blocks on ESC[6n until answered.
2026-09-12 22:09:19 -07:00

119 lines
4.6 KiB
Python

"""Answer a bounded set of blocking terminal queries from PTY subprocesses.
Programs running inside a background PTY session (``terminal(background=true,
pty=true)``) sometimes probe their "terminal" with ANSI queries — device
status reports (``ESC[5n``), window-size queries (``ESC[18t``), cursor
position reports (``ESC[6n``), or DEC private-mode queries
(``ESC[?<mode>$p``). A real terminal emulator answers these on stdin; Hermes'
PTY has no emulator on the master side, so the subprocess either blocks
forever waiting for a reply or the raw query bytes leak into the captured
output as garbage.
This module ports openai/codex#41436 (``terminal_queries.rs``): a tiny
byte-level state machine that scans PTY output for the handled queries,
strips them from the output stream (queries split across read chunks
included), and produces the bounded responses to write back to the
subprocess. Everything else — colors, other escape sequences, partial
UTF-8 — passes through untouched.
Only the POSIX ``ptyprocess`` path uses this. On Windows, ConPTY is a real
console host that answers queries itself.
"""
from __future__ import annotations
_ESC = 0x1B
# DEC private-mode queries carry a numeric mode of bounded length; anything
# longer is not a query we answer (and is passed through untouched).
_MAX_MODE_DIGITS = 10
# Longest handled sequence: ESC [ ? <digits> $ p
_MAX_QUERY_BYTES = _MAX_MODE_DIGITS + 5
class PtyQueryResponder:
"""Incremental scanner for terminal queries in a PTY output stream.
Feed raw output chunks through :meth:`process`; it returns the chunk with
any handled queries removed, plus the response bytes to write to the
subprocess's stdin. Call :meth:`flush` at end-of-stream to recover any
trailing partial escape sequence that never completed.
"""
def __init__(self, rows: int = 24, cols: int = 80):
# Exact-match queries and their responses. Window size reports the
# PTY's actual spawn dimensions; cursor position reports home (1;1) —
# we don't emulate a screen, a bounded answer just unblocks the
# subprocess (same policy as openai/codex#41436).
self._query_responses: tuple[tuple[bytes, bytes], ...] = (
# Device status report: terminal operating normally.
(b"\x1b[5n", b"\x1b[0n"),
# Window-size query: report the PTY's row/col text area.
(b"\x1b[18t", b"\x1b[8;%d;%dt" % (rows, cols)),
# Cursor-position report: row 1, column 1.
(b"\x1b[6n", b"\x1b[1;1R"),
)
self._pending = bytearray()
def process(self, data: bytes) -> tuple[bytes, bytes]:
"""Scan ``data``; return ``(output_bytes, response_bytes)``."""
if not self._pending and _ESC not in data:
return data, b""
output = bytearray()
responses = bytearray()
pending = self._pending
for byte in data:
if not pending and byte != _ESC:
output.append(byte)
continue
if byte == _ESC:
# A fresh ESC aborts any partial sequence — flush it through.
output += pending
pending.clear()
pending.append(byte)
if (
len(pending) == 1
or bytes(pending) == b"\x1b["
or (
pending[1] == ord("[")
and not (0x40 <= byte <= 0x7E)
and len(pending) < _MAX_QUERY_BYTES
)
):
# Still accumulating a possible query.
continue
seq = bytes(pending)
matched = False
for query, response in self._query_responses:
if seq == query:
responses += response
matched = True
break
if not matched:
mode = seq[3:-2]
if (
seq.startswith(b"\x1b[?")
and seq.endswith(b"$p")
and 0 < len(mode) <= _MAX_MODE_DIGITS
and mode.isdigit()
):
# DEC private-mode query: report mode as unrecognized.
responses += b"\x1b[?" + mode + b";0$y"
else:
# Not a handled query — pass the sequence through.
output += pending
pending.clear()
return bytes(output), bytes(responses)
def flush(self) -> bytes:
"""Return any incomplete trailing sequence held back by the scanner."""
tail = bytes(self._pending)
self._pending.clear()
return tail