Python web server that wraps Clide for browser access. Replaces the previous ttyd (C binary) + zellij (Rust binary) stack with a single FastAPI application using tmux for session persistence. Key features: - WebSocket ↔ PTY bridge via tmux attach - Project switching via /projects/<name> URL routing - Vendored xterm.js for offline LAN operation - Auto-respawn on Clide exit (tmux pane-died hook) - Setup wizard for first-run configuration - No scrollbar (TUI manages its own scrolling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
5.0 KiB
Python
170 lines
5.0 KiB
Python
"""PTY bridge: fork a PTY running tmux attach, async I/O to WebSocket clients."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import fcntl
|
|
import os
|
|
import pty
|
|
import signal
|
|
import struct
|
|
import termios
|
|
from collections.abc import Callable
|
|
|
|
|
|
class PtyBridge:
|
|
"""Manages a single PTY connection to a tmux session.
|
|
|
|
One PtyBridge per WebSocket connection. The PTY runs `tmux attach -t <session>`.
|
|
When the WebSocket disconnects, the PTY is killed but the tmux session persists.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
tmux_session: str,
|
|
on_output: Callable[[bytes], None],
|
|
on_exit: Callable[[], None],
|
|
rows: int = 40,
|
|
cols: int = 120,
|
|
) -> None:
|
|
self._tmux_session = tmux_session
|
|
self._on_output = on_output
|
|
self._on_exit = on_exit
|
|
self._rows = rows
|
|
self._cols = cols
|
|
self._pid: int | None = None
|
|
self._master_fd: int | None = None
|
|
self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
if self._pid is None:
|
|
return False
|
|
try:
|
|
pid, _ = os.waitpid(self._pid, os.WNOHANG)
|
|
return pid == 0
|
|
except ChildProcessError:
|
|
return False
|
|
|
|
def start(self) -> None:
|
|
"""Fork a PTY and exec tmux attach."""
|
|
pid, master_fd = pty.fork()
|
|
|
|
if pid == 0:
|
|
# Child process
|
|
os.environ["TERM"] = "xterm-256color"
|
|
os.environ["COLORTERM"] = "truecolor"
|
|
os.environ["COLUMNS"] = str(self._cols)
|
|
os.environ["LINES"] = str(self._rows)
|
|
os.execvp("tmux", ["tmux", "attach-session", "-t", self._tmux_session])
|
|
else:
|
|
# Parent process
|
|
self._pid = pid
|
|
self._master_fd = master_fd
|
|
|
|
# Set non-blocking I/O
|
|
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
|
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
|
|
# Set initial PTY size
|
|
self._set_pty_size(self._rows, self._cols)
|
|
|
|
# Start async read loop
|
|
self._read_task = asyncio.create_task(self._read_output())
|
|
|
|
async def _read_output(self) -> None:
|
|
"""Read PTY output and forward to callback."""
|
|
if self._master_fd is None:
|
|
return
|
|
|
|
loop = asyncio.get_event_loop()
|
|
fd = self._master_fd
|
|
|
|
try:
|
|
while True:
|
|
# Wait for data using event loop (more efficient than polling)
|
|
await _wait_for_fd(loop, fd)
|
|
|
|
try:
|
|
data = os.read(fd, 65536)
|
|
if not data:
|
|
break
|
|
self._on_output(data)
|
|
except BlockingIOError:
|
|
continue
|
|
except OSError:
|
|
break
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
self._on_exit()
|
|
|
|
def write(self, data: bytes) -> None:
|
|
"""Write input data to the PTY."""
|
|
if self._master_fd is not None:
|
|
try:
|
|
os.write(self._master_fd, data)
|
|
except OSError:
|
|
pass
|
|
|
|
def resize(self, rows: int, cols: int) -> None:
|
|
"""Resize the PTY and notify the child process."""
|
|
self._rows = rows
|
|
self._cols = cols
|
|
if self._master_fd is not None:
|
|
self._set_pty_size(rows, cols)
|
|
|
|
def _set_pty_size(self, rows: int, cols: int) -> None:
|
|
"""Set PTY window size via ioctl and send SIGWINCH."""
|
|
if self._master_fd is None:
|
|
return
|
|
try:
|
|
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
|
if self._pid is not None:
|
|
try:
|
|
os.kill(self._pid, signal.SIGWINCH)
|
|
except OSError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
|
|
def stop(self) -> None:
|
|
"""Kill the PTY process and clean up."""
|
|
if self._read_task:
|
|
self._read_task.cancel()
|
|
self._read_task = None
|
|
|
|
if self._pid is not None:
|
|
try:
|
|
os.kill(self._pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.waitpid(self._pid, 0)
|
|
except ChildProcessError:
|
|
pass
|
|
self._pid = None
|
|
|
|
if self._master_fd is not None:
|
|
try:
|
|
os.close(self._master_fd)
|
|
except OSError:
|
|
pass
|
|
self._master_fd = None
|
|
|
|
|
|
async def _wait_for_fd(loop: asyncio.AbstractEventLoop, fd: int) -> None:
|
|
"""Wait until a file descriptor has data ready to read."""
|
|
future: asyncio.Future[None] = loop.create_future()
|
|
|
|
def _ready() -> None:
|
|
if not future.done():
|
|
future.set_result(None)
|
|
|
|
loop.add_reader(fd, _ready)
|
|
try:
|
|
await future
|
|
finally:
|
|
loop.remove_reader(fd)
|