Files
wild-pc/castle-api/src/castle_api/pty_session.py
Paul Payne 4c12882cc7 fix(agents): terminal fills the panel on maximize
- Mount xterm as an absolute inset-0 fill so it always has an explicit box and
  fit() measures the real size when the dock expands (was collapsing via the
  flex/percentage height chain).
- Acquire the pty slave as the child's controlling terminal (setsid + TIOCSCTTY)
  so the kernel delivers SIGWINCH on a browser resize. Previously the initial
  size was read once but live resizes were dropped, so the pane stayed at its
  startup size — content never grew with the window.
2026-07-02 04:02:18 -07:00

173 lines
5.9 KiB
Python

"""Run a command inside a pseudo-terminal and stream it over asyncio.
Castle stays assistant-agnostic: it just launches a command in a real TTY and
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
renders exactly as it would in a terminal — Castle never parses its output.
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
``on_output`` callback (a fan-out layer above can broadcast it to any number of
attached clients and buffer scrollback), so a session's lifetime is decoupled
from any one WebSocket connection. Keystroke writes are tiny, so a plain
``os.write`` is fine.
"""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import signal
import struct
import termios
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
def _set_winsize(fd: int, rows: int, cols: int) -> None:
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _acquire_controlling_tty() -> None:
"""Child-side setup (preexec), run after the pty slave is dup'd to fd 0/1/2.
``setsid()`` starts a new session (own process group, so cleanup can
``killpg`` the whole tree). ``TIOCSCTTY`` then makes the slave our
*controlling terminal* — without it the kernel has no foreground process
group to deliver ``SIGWINCH`` to, so a later winsize change (a browser resize)
is silently dropped and the app never reflows. This is what makes live resize
work, not just the initial size.
"""
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
@dataclass
class PtySession:
"""A child process attached to a pty master.
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
fired once when the child closes the tty. Both run in the event-loop thread.
"""
proc: asyncio.subprocess.Process
master_fd: int
on_output: Callable[[bytes], None] | None = None
on_exit: Callable[[], None] | None = None
cols: int = 80
rows: int = 24
_closed: bool = field(default=False)
@classmethod
async def start(
cls,
command: str,
args: list[str] | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
cols: int = 80,
rows: int = 24,
on_output: Callable[[bytes], None] | None = None,
on_exit: Callable[[], None] | None = None,
) -> PtySession:
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
master_fd, slave_fd = pty.openpty()
# Size the tty before exec so the first TUI render lays out correctly.
_set_winsize(master_fd, rows, cols)
proc_env = {**os.environ, "TERM": "xterm-256color"}
if env:
proc_env.update(env)
proc = await asyncio.create_subprocess_exec(
command,
*(args or []),
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=str(cwd) if cwd else None,
env=proc_env,
# setsid + TIOCSCTTY in the child: own session/group (for killpg) AND
# the slave as controlling tty (for SIGWINCH resize delivery).
preexec_fn=_acquire_controlling_tty,
close_fds=True,
)
os.close(slave_fd) # parent keeps only the master
os.set_blocking(master_fd, False)
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
if on_output is not None:
session.on_output = on_output
if on_exit is not None:
session.on_exit = on_exit
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
return session
def _drain_master(self) -> None:
"""Reader callback: pull available bytes off the master and fan them out."""
try:
data = os.read(self.master_fd, 65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b"" # slave closed → child gone
if not data:
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.on_exit is not None:
self.on_exit()
return
if self.on_output is not None:
self.on_output(data)
def write(self, data: bytes) -> None:
"""Forward keystroke bytes to the child's tty."""
try:
os.write(self.master_fd, data)
except OSError:
pass
def resize(self, cols: int, rows: int) -> None:
"""Resize the tty (browser terminal changed dimensions)."""
self.cols, self.rows = cols, rows
try:
_set_winsize(self.master_fd, rows, cols)
except OSError:
pass
@property
def running(self) -> bool:
return self.proc.returncode is None
async def close(self) -> None:
"""Kill the whole process group and release the master fd (idempotent)."""
if self._closed:
return
self._closed = True
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.proc.returncode is None:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(self.proc.wait(), timeout=3)
except asyncio.TimeoutError:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.close(self.master_fd)
except OSError:
pass