feat: add interactive terminal and dev server management

Add new features for interactive terminal sessions and dev server control:

Terminal Component:
- New Terminal.tsx component using xterm.js for full terminal emulation
- WebSocket-based PTY communication with bidirectional I/O
- Cross-platform support (Windows via winpty, Unix via built-in pty)
- Auto-reconnection with exponential backoff
- Fix duplicate WebSocket connection bug by checking CONNECTING state
- Add manual close flag to prevent auto-reconnect race conditions
- Add project tracking to avoid duplicate connects on initial activation

Dev Server Management:
- New DevServerControl.tsx for starting/stopping dev servers
- DevServerManager service for subprocess management
- WebSocket streaming of dev server output
- Project configuration service for reading package.json scripts

Backend Infrastructure:
- Terminal router with WebSocket endpoint for PTY I/O
- DevServer router for server lifecycle management
- Terminal session manager with callback-based output streaming
- Enhanced WebSocket schemas for terminal and dev server messages

UI Integration:
- New Terminal and Dev Server tabs in the main application
- Updated DebugLogViewer with improved UI and functionality
- Extended useWebSocket hook for terminal message handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-12 10:35:36 +02:00
parent b1473cdfb9
commit c1985eb285
22 changed files with 3360 additions and 66 deletions

View File

@@ -7,20 +7,24 @@ FastAPI routers for different API endpoints.
from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .devserver import router as devserver_router
from .expand_project import router as expand_project_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .settings import router as settings_router
from .spec_creation import router as spec_creation_router
from .terminal import router as terminal_router
__all__ = [
"projects_router",
"features_router",
"agent_router",
"devserver_router",
"spec_creation_router",
"expand_project_router",
"filesystem_router",
"assistant_chat_router",
"settings_router",
"terminal_router",
]

280
server/routers/devserver.py Normal file
View File

@@ -0,0 +1,280 @@
"""
Dev Server Router
=================
API endpoints for dev server control (start/stop) and configuration.
Uses project registry for path lookups and project_config for command detection.
"""
import re
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
DevServerActionResponse,
DevServerConfigResponse,
DevServerConfigUpdate,
DevServerStartRequest,
DevServerStatus,
)
from ..services.dev_server_manager import get_devserver_manager
from ..services.project_config import (
clear_dev_command,
get_dev_command,
get_project_config,
set_dev_command,
)
# Add root to path for registry import
_root = Path(__file__).parent.parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import get_project_path as registry_get_project_path
def _get_project_path(project_name: str) -> Path | None:
"""Get project path from registry."""
return registry_get_project_path(project_name)
router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"])
# ============================================================================
# Helper Functions
# ============================================================================
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name"
)
return name
def get_project_dir(project_name: str) -> Path:
"""
Get the validated project directory for a project name.
Args:
project_name: Name of the project
Returns:
Path to the project directory
Raises:
HTTPException: If project is not found or directory does not exist
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(
status_code=404,
detail=f"Project '{project_name}' not found in registry"
)
if not project_dir.exists():
raise HTTPException(
status_code=404,
detail=f"Project directory not found: {project_dir}"
)
return project_dir
def get_project_devserver_manager(project_name: str):
"""
Get the dev server process manager for a project.
Args:
project_name: Name of the project
Returns:
DevServerProcessManager instance for the project
Raises:
HTTPException: If project is not found or directory does not exist
"""
project_dir = get_project_dir(project_name)
return get_devserver_manager(project_name, project_dir)
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/status", response_model=DevServerStatus)
async def get_devserver_status(project_name: str) -> DevServerStatus:
"""
Get the current status of the dev server for a project.
Returns information about whether the dev server is running,
its process ID, detected URL, and the command used to start it.
"""
manager = get_project_devserver_manager(project_name)
# Run healthcheck to detect crashed processes
await manager.healthcheck()
return DevServerStatus(
status=manager.status,
pid=manager.pid,
url=manager.detected_url,
command=manager._command,
started_at=manager.started_at,
)
@router.post("/start", response_model=DevServerActionResponse)
async def start_devserver(
project_name: str,
request: DevServerStartRequest = DevServerStartRequest(),
) -> DevServerActionResponse:
"""
Start the dev server for a project.
If a custom command is provided in the request, it will be used.
Otherwise, the effective command from the project configuration is used.
Args:
project_name: Name of the project
request: Optional start request with custom command
Returns:
Response indicating success/failure and current status
"""
manager = get_project_devserver_manager(project_name)
project_dir = get_project_dir(project_name)
# Determine which command to use
command: str | None
if request.command:
command = request.command
else:
command = get_dev_command(project_dir)
if not command:
raise HTTPException(
status_code=400,
detail="No dev command available. Configure a custom command or ensure project type can be detected."
)
# Now command is definitely str
success, message = await manager.start(command)
return DevServerActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/stop", response_model=DevServerActionResponse)
async def stop_devserver(project_name: str) -> DevServerActionResponse:
"""
Stop the dev server for a project.
Gracefully terminates the dev server process and all its child processes.
Args:
project_name: Name of the project
Returns:
Response indicating success/failure and current status
"""
manager = get_project_devserver_manager(project_name)
success, message = await manager.stop()
return DevServerActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.get("/config", response_model=DevServerConfigResponse)
async def get_devserver_config(project_name: str) -> DevServerConfigResponse:
"""
Get the dev server configuration for a project.
Returns information about:
- detected_type: The auto-detected project type (nodejs-vite, python-django, etc.)
- detected_command: The default command for the detected type
- custom_command: Any user-configured custom command
- effective_command: The command that will actually be used (custom or detected)
Args:
project_name: Name of the project
Returns:
Configuration details for the project's dev server
"""
project_dir = get_project_dir(project_name)
config = get_project_config(project_dir)
return DevServerConfigResponse(
detected_type=config["detected_type"],
detected_command=config["detected_command"],
custom_command=config["custom_command"],
effective_command=config["effective_command"],
)
@router.patch("/config", response_model=DevServerConfigResponse)
async def update_devserver_config(
project_name: str,
update: DevServerConfigUpdate,
) -> DevServerConfigResponse:
"""
Update the dev server configuration for a project.
Set custom_command to a string to override the auto-detected command.
Set custom_command to null/None to clear the custom command and revert
to using the auto-detected command.
Args:
project_name: Name of the project
update: Configuration update containing the new custom_command
Returns:
Updated configuration details for the project's dev server
"""
project_dir = get_project_dir(project_name)
# Update the custom command
if update.custom_command is None:
# Clear the custom command
try:
clear_dev_command(project_dir)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
else:
# Set the custom command
try:
set_dev_command(project_dir, update.custom_command)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save configuration: {e}"
)
# Return updated config
config = get_project_config(project_dir)
return DevServerConfigResponse(
detected_type=config["detected_type"],
detected_command=config["detected_command"],
custom_command=config["custom_command"],
effective_command=config["effective_command"],
)

273
server/routers/terminal.py Normal file
View File

@@ -0,0 +1,273 @@
"""
Terminal Router
===============
WebSocket endpoint for interactive terminal I/O with PTY support.
Provides real-time bidirectional communication with terminal sessions.
"""
import asyncio
import base64
import json
import logging
import re
import sys
from pathlib import Path
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ..services.terminal_manager import get_terminal_session
# Add project root to path for registry import
_root = Path(__file__).parent.parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import get_project_path as registry_get_project_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/terminal", tags=["terminal"])
class TerminalCloseCode:
"""WebSocket close codes for terminal endpoint."""
INVALID_PROJECT_NAME = 4000
PROJECT_NOT_FOUND = 4004
FAILED_TO_START = 4500
def _get_project_path(project_name: str) -> Path | None:
"""Get project path from registry."""
return registry_get_project_path(project_name)
def validate_project_name(name: str) -> bool:
"""
Validate project name to prevent path traversal attacks.
Allows only alphanumeric characters, underscores, and hyphens.
Maximum length of 50 characters.
Args:
name: The project name to validate
Returns:
True if valid, False otherwise
"""
return bool(re.match(r"^[a-zA-Z0-9_-]{1,50}$", name))
@router.websocket("/ws/{project_name}")
async def terminal_websocket(websocket: WebSocket, project_name: str) -> None:
"""
WebSocket endpoint for interactive terminal I/O.
Message protocol:
Client -> Server:
- {"type": "input", "data": "<base64-encoded-bytes>"} - Keyboard input
- {"type": "resize", "cols": 80, "rows": 24} - Terminal resize
- {"type": "ping"} - Keep-alive ping
Server -> Client:
- {"type": "output", "data": "<base64-encoded-bytes>"} - PTY output
- {"type": "exit", "code": 0} - Shell process exited
- {"type": "pong"} - Keep-alive response
- {"type": "error", "message": "..."} - Error message
"""
# Validate project name
if not validate_project_name(project_name):
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
)
return
# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project not found in registry",
)
return
if not project_dir.exists():
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project directory not found",
)
return
await websocket.accept()
# Get or create terminal session for this project
session = get_terminal_session(project_name, project_dir)
# Queue for output data to send to client
output_queue: asyncio.Queue[bytes] = asyncio.Queue()
# Callback to receive terminal output and queue it for sending
def on_output(data: bytes) -> None:
"""Queue terminal output for async sending to WebSocket."""
try:
output_queue.put_nowait(data)
except asyncio.QueueFull:
logger.warning(f"Output queue full for {project_name}, dropping data")
# Register the output callback
session.add_output_callback(on_output)
# Start the terminal session if not already active
if not session.is_active:
started = await session.start()
if not started:
session.remove_output_callback(on_output)
try:
await websocket.send_json(
{"type": "error", "message": "Failed to start terminal session"}
)
except Exception:
pass
await websocket.close(
code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal"
)
return
# Task to send queued output to WebSocket
async def send_output_task() -> None:
"""Continuously send queued output to the WebSocket client."""
try:
while True:
# Wait for output data
data = await output_queue.get()
# Encode as base64 and send
encoded = base64.b64encode(data).decode("ascii")
await websocket.send_json({"type": "output", "data": encoded})
except asyncio.CancelledError:
raise
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(f"Error sending output for {project_name}: {e}")
raise
# Task to monitor if the terminal session exits
async def monitor_exit_task() -> None:
"""Monitor the terminal session and notify client on exit."""
try:
while session.is_active:
await asyncio.sleep(0.5)
# Session ended - send exit message
# Note: We don't have access to actual exit code from PTY
await websocket.send_json({"type": "exit", "code": 0})
except asyncio.CancelledError:
raise
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(f"Error in exit monitor for {project_name}: {e}")
# Start background tasks
output_task = asyncio.create_task(send_output_task())
exit_task = asyncio.create_task(monitor_exit_task())
try:
while True:
try:
# Receive message from client
data = await websocket.receive_text()
message = json.loads(data)
msg_type = message.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "input":
# Decode base64 input and write to PTY
encoded_data = message.get("data", "")
# Add size limit to prevent DoS
if len(encoded_data) > 65536: # 64KB limit for base64 encoded data
await websocket.send_json({"type": "error", "message": "Input too large"})
continue
if encoded_data:
try:
decoded = base64.b64decode(encoded_data)
except (ValueError, TypeError) as e:
logger.warning(f"Failed to decode base64 input: {e}")
await websocket.send_json(
{"type": "error", "message": "Invalid base64 data"}
)
continue
try:
session.write(decoded)
except Exception as e:
logger.warning(f"Failed to write to terminal: {e}")
await websocket.send_json(
{"type": "error", "message": "Failed to write to terminal"}
)
elif msg_type == "resize":
# Resize the terminal
cols = message.get("cols", 80)
rows = message.get("rows", 24)
# Validate dimensions
if isinstance(cols, int) and isinstance(rows, int):
cols = max(10, min(500, cols))
rows = max(5, min(200, rows))
session.resize(cols, rows)
else:
await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"})
else:
await websocket.send_json({"type": "error", "message": f"Unknown message type: {msg_type}"})
except json.JSONDecodeError:
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
except WebSocketDisconnect:
logger.info(f"Terminal WebSocket disconnected for {project_name}")
except Exception as e:
logger.exception(f"Terminal WebSocket error for {project_name}")
try:
await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"})
except Exception:
pass
finally:
# Cancel background tasks
output_task.cancel()
exit_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
try:
await exit_task
except asyncio.CancelledError:
pass
# Remove the output callback
session.remove_output_callback(on_output)
# Only stop session if no other clients are connected
with session._callbacks_lock:
remaining_callbacks = len(session._output_callbacks)
if remaining_callbacks == 0:
await session.stop()
logger.info(f"Terminal session stopped for {project_name} (last client disconnected)")
else:
logger.info(
f"Client disconnected from {project_name}, {remaining_callbacks} clients remaining"
)