"""Terminal session management for tool instances.""" import asyncio import os import pty import select import struct import fcntl import uuid from typing import Any class TerminalSession: """Manages a single terminal session connected to a docker container.""" def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None: self.session_id = session_id self.instance_id = instance_id self.container_id = container_id self.process: asyncio.subprocess.Process | None = None self._closed = False self._master_fd: int | None = None self._slave_fd: int | None = None async def start(self) -> None: """Start the docker exec process with a shell using a PTY.""" # Create a pseudo-terminal on the host self._master_fd, self._slave_fd = pty.openpty() # Set the terminal size initially self._set_terminal_size(80, 24) # Start docker exec with the slave fd as stdin/stdout/stderr # Using -it because the slave fd IS a TTY self.process = await asyncio.create_subprocess_exec( "docker", "exec", "-it", "-e", "TERM=xterm", self.container_id, "bash", "-il", stdin=self._slave_fd, stdout=self._slave_fd, stderr=self._slave_fd, ) # Close slave fd in parent process os.close(self._slave_fd) self._slave_fd = None def _set_terminal_size(self, cols: int, rows: int) -> None: """Set the terminal size using TIOCSWINSZ.""" if self._master_fd is None: return # TIOCSWINSZ = 0x5414 on Linux TIOCSWINSZ = 0x5414 size = struct.pack('HHHH', rows, cols, 0, 0) try: fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) except (OSError, IOError): pass async def read_output(self) -> bytes: """Read output from the PTY master.""" if self._master_fd is None or self._closed: return b"" try: # Use select to check if data is available readable, _, _ = select.select([self._master_fd], [], [], 0.1) if readable: return os.read(self._master_fd, 4096) return b"" except (OSError, IOError, ValueError): return b"" async def write_input(self, data: bytes) -> None: """Write input to the PTY master.""" if self._master_fd is None or self._closed: return try: os.write(self._master_fd, data) except (OSError, IOError): pass async def resize(self, cols: int, rows: int) -> None: """Resize the terminal.""" if self._closed: return self._set_terminal_size(cols, rows) async def close(self) -> None: """Close the session and cleanup.""" if self._closed: return self._closed = True if self._master_fd is not None: try: os.close(self._master_fd) except OSError: pass self._master_fd = None if self.process is not None: try: self.process.kill() await asyncio.wait_for(self.process.wait(), timeout=2.0) except (asyncio.TimeoutError, ProcessLookupError): pass def is_alive(self) -> bool: """Check if the session process is still running.""" if self.process is None: return False return self.process.returncode is None