From 29e4bed9e66cb14f8346e594680641462b9e97d4 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 20:35:07 +0200 Subject: [PATCH 1/5] fix: use docker exec -i instead of -it so host PTY resize propagates to container Removes -t flag from docker exec so it uses our PTY slave directly instead of creating its own PTY inside the container. This allows TIOCSWINSZ on the host PTY master to propagate naturally to the container shell via SIGWINCH. Also removes all stty command injection logic since resize now works natively. --- apps/api/src/services/terminal_session.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index db26918..a8c1f50 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -61,11 +61,15 @@ class TerminalSession: logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}") # Start docker exec with the slave fd as stdin/stdout/stderr - # Using -it because the slave fd IS a TTY + # Using -i (interactive) but NOT -t (tty) because: + # 1. The slave fd IS a TTY + # 2. docker exec -t creates its OWN PTY inside the container + # 3. This prevents host PTY resize from propagating to the container shell + # By using only -i, docker exec uses our PTY slave directly self.process = await asyncio.create_subprocess_exec( "docker", "exec", - "-it", + "-i", "-e", "TERM=xterm", self.container_id, @@ -151,17 +155,6 @@ class TerminalSession: self._rows = rows logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}") self._set_terminal_size(cols, rows) - - # Docker exec doesn't forward PTY resize to the container process, - # so we need to explicitly set the size inside the container shell. - # Send on every resize so the container shell always matches the frontend. - # Use stty -echo to prevent the command from being visible, then clear the line. - stty_cmd = ( - f"stty -echo; stty cols {cols} rows {rows}; stty echo\n" - f"\x1b[A\x1b[M" # Move up 1 line and delete it (clears the stty command) - ).encode() - await self.write_input(stty_cmd) - logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}") async def reset(self) -> None: """Reset the session by killing the process and clearing state.""" From 5f5dc9c85116cf8788cbc1e99edcf5917e96041f Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 20:40:02 +0200 Subject: [PATCH 2/5] fix: revert docker exec -i change and use stty with line hiding Reverted docker exec back to -it (required for interactive bash). Instead, sends stty command with \r to hide it from the terminal display: - \r moves cursor to start of line (overwrites prompt) - stty command executes silently (no output on success) - \r moves cursor back to start, hiding echoed command This sends stty on EVERY resize so the container shell always matches frontend dimensions. --- apps/api/src/services/terminal_session.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index a8c1f50..034b86f 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -61,15 +61,11 @@ class TerminalSession: logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}") # Start docker exec with the slave fd as stdin/stdout/stderr - # Using -i (interactive) but NOT -t (tty) because: - # 1. The slave fd IS a TTY - # 2. docker exec -t creates its OWN PTY inside the container - # 3. This prevents host PTY resize from propagating to the container shell - # By using only -i, docker exec uses our PTY slave directly + # Using -it because the slave fd IS a TTY self.process = await asyncio.create_subprocess_exec( "docker", "exec", - "-i", + "-it", "-e", "TERM=xterm", self.container_id, @@ -155,6 +151,16 @@ class TerminalSession: self._rows = rows logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}") self._set_terminal_size(cols, rows) + + # Docker exec -it creates its own PTY inside the container, + # so host PTY resize doesn't propagate. We must send stty manually. + # To hide the command from the user: + # 1. \r moves cursor to start of current line (overwrites prompt) + # 2. stty command executes silently (no output on success) + # 3. \r moves cursor back to start, hiding the echoed command + stty_cmd = f"\rstty cols {cols} rows {rows}\r".encode() + await self.write_input(stty_cmd) + logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}") async def reset(self) -> None: """Reset the session by killing the process and clearing state.""" From 0cb2eefd29c526bdbcdea258541532d2f87712eb Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 20:47:14 +0200 Subject: [PATCH 3/5] fix: send SIGWINCH to docker exec process for container terminal resize Instead of sending stty commands through the user's terminal session (which causes 'inappropriate ioctl' errors), send SIGWINCH signal to the docker exec process on the host. Docker exec should forward this to the container process, causing the shell to re-read its terminal size. This avoids: - Visible stty commands in the terminal - ioctl errors from stty - Interference with user's shell session --- apps/api/src/services/terminal_session.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index 034b86f..0557a8b 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -5,6 +5,7 @@ import logging import os import pty import select +import signal import struct import fcntl import time @@ -153,14 +154,18 @@ class TerminalSession: self._set_terminal_size(cols, rows) # Docker exec -it creates its own PTY inside the container, - # so host PTY resize doesn't propagate. We must send stty manually. - # To hide the command from the user: - # 1. \r moves cursor to start of current line (overwrites prompt) - # 2. stty command executes silently (no output on success) - # 3. \r moves cursor back to start, hiding the echoed command - stty_cmd = f"\rstty cols {cols} rows {rows}\r".encode() - await self.write_input(stty_cmd) - logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}") + # so host PTY resize doesn't propagate to the container shell. + # Send SIGWINCH to the docker exec process on the host. + # Docker exec forwards signals to the container process, which should + # cause the container's shell to re-read its terminal size. + if self.process and self.process.pid: + try: + os.kill(self.process.pid, signal.SIGWINCH) + logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}") + except ProcessLookupError: + logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}") + except Exception as e: + logger.warning(f"Failed to send SIGWINCH: {e}") async def reset(self) -> None: """Reset the session by killing the process and clearing state.""" From 33b482ce9138f079e4c9ff127bdde6bdc4e7f967 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 20:57:33 +0200 Subject: [PATCH 4/5] fix: terminal reconnect typing and reset functionality Frontend: - Fix term.onData to use wsRef.current instead of captured ws variable - Fix fitTerminal to use wsRef.current for resize messages - Fix sendData callback to use wsRef.current - This fixes 'cannot type' after WebSocket reconnect Backend: - Add SessionRef class for mutable session reference - Update _read_loop and _write_loop to use SessionRef - Reset now updates session_ref.session instead of returning - This keeps the WebSocket alive after reset instead of closing it --- apps/api/src/api/terminal.py | 39 +++++++++++++++++++++------- apps/web/src/components/terminal.tsx | 17 +++++++----- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index c582e33..22541ea 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -15,6 +15,13 @@ router = APIRouter() logger = logging.getLogger(__name__) +class SessionRef: + """Mutable reference to a terminal session, allowing updates during reset.""" + + def __init__(self, session): + self.session = session + + @router.websocket( "/ws/tool-instances/{instance_id}/terminal", ) @@ -86,9 +93,12 @@ async def terminal_websocket( # Send connected status await websocket.send_json({"type": "status", "status": "connected"}) + # Use mutable session reference so loops can survive reset + session_ref = SessionRef(session) + # Start I/O loops and heartbeat - read_task = asyncio.create_task(_read_loop(session, websocket)) - write_task = asyncio.create_task(_write_loop(session, websocket, instance_id)) + read_task = asyncio.create_task(_read_loop(session_ref, websocket)) + write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id)) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) # Wait for either task to complete (indicating disconnect or error) @@ -114,10 +124,14 @@ async def terminal_websocket( pass -async def _read_loop(session, websocket) -> None: +async def _read_loop(session_ref: SessionRef, websocket) -> None: """Read output from the container and send to WebSocket.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue data = await session.read_output() if data: try: @@ -130,10 +144,14 @@ async def _read_loop(session, websocket) -> None: pass -async def _write_loop(session, websocket, instance_id: str) -> None: +async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: """Read input from WebSocket and send to container.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue message = await websocket.receive() if message["type"] == "websocket.receive": if "bytes" in message: @@ -163,14 +181,15 @@ async def _write_loop(session, websocket, instance_id: str) -> None: session.container_id, ) + # Update the mutable session reference so read_loop uses the new session + session_ref.session = new_session + # Attach to new session await terminal_manager.attach_websocket(new_session, websocket) await websocket.send_json({"type": "status", "status": "connected"}) - # Update session reference and restart loops - # Note: This will cause the current loops to exit - # The WebSocket handler will create new ones - return + # Continue the loop with the new session + continue except json.JSONDecodeError: pass diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index dc441aa..e3ae4ff 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -225,8 +225,9 @@ export const TerminalComponent: React.FC = ({ if (cols !== oldCols || rows !== oldRows) { termRef.current.refresh(0, rows - 1); } - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } }; @@ -244,20 +245,21 @@ export const TerminalComponent: React.FC = ({ // Handle terminal input term.onData((data) => { - if (ws.readyState !== WebSocket.OPEN) return; + const currentWs = wsRef.current; + if (currentWs?.readyState !== WebSocket.OPEN) return; // Apply active modifier to single-character input const modifier = activeModifierRef.current; if (modifier && data.length === 1) { const modified = applyModifierToChar(data, modifier); if (modified) { - ws.send(modified); + currentWs.send(modified); onModifierChange?.(null); return; } } - ws.send(data); + currentWs.send(data); }); // Handle container resize with ResizeObserver for accurate dimension tracking @@ -302,8 +304,9 @@ export const TerminalComponent: React.FC = ({ // Notify parent about terminal readiness if (onTerminalReadyRef.current) { const sendData = (data: string) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(data); } }; const focusInput = () => { From 51b5d723acc14a4bf26e919b00cd82310b963037 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 21:15:04 +0200 Subject: [PATCH 5/5] fix: terminal clear on reset and data loss for text starting with { Frontend: - Clear xterm.js screen when receiving 'connected' status after reset - Send resize message after clearing to ensure proper dimensions - Fixes terminal artifacts after reset Backend: - Fix data loss bug: text starting with '{' but not valid JSON was silently dropped - Now writes such text to session as regular input - Fixes missing characters when user types '{' --- apps/api/src/api/terminal.py | 3 ++- apps/web/src/components/terminal.tsx | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index 22541ea..38b5116 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -192,7 +192,8 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N continue except json.JSONDecodeError: - pass + # Not a valid JSON control message, treat as regular input + await session.write_input(text.encode("utf-8")) else: await session.write_input(text.encode("utf-8")) elif message["type"] == "websocket.disconnect": diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index e3ae4ff..fe8e969 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -115,6 +115,20 @@ export const TerminalComponent: React.FC = ({ if (msg.status === "connected") { setStatus("connected"); setError(null); + // Clear terminal and refit after reset/reconnect + if (termRef.current) { + termRef.current.clear(); + requestAnimationFrame(() => { + if (fitAddonRef.current && termRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); + } + } + }); + } } else if (msg.status === "resetting") { setStatus("resetting"); } @@ -210,11 +224,7 @@ export const TerminalComponent: React.FC = ({ const container = terminalRef.current; let ws: WebSocket; - // Open xterm immediately - term.open(container); - ws = connectWebSocket(); - - // Fit terminal and notify backend + // Define fitTerminal before connectWebSocket so it's available in onmessage const fitTerminal = () => { if (!fitAddonRef.current || !termRef.current) return; const oldCols = termRef.current.cols; @@ -238,6 +248,10 @@ export const TerminalComponent: React.FC = ({ }); }); + // Open xterm immediately + term.open(container); + ws = connectWebSocket(); + // Refit after font load (metrics may change) document.fonts.ready.then(() => { requestAnimationFrame(() => fitTerminal());