""" Terminal WebSocket for Claude Code CLI Provides a PTY-based terminal that runs Claude Code in the dashboard. Uses pywinpty on Windows for proper interactive terminal support. """ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from typing import Optional import asyncio import subprocess import sys import os import signal import json router = APIRouter() # Store active terminal sessions _terminal_sessions: dict = {} # Path to Atomizer root (for loading prompts) # Go up 5 levels: terminal.py -> routes -> api -> backend -> atomizer-dashboard -> Atomizer _file_path = os.path.abspath(__file__) ATOMIZER_ROOT = os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname( os.path.dirname(os.path.dirname(_file_path)) )))) STUDIES_DIR = os.path.join(ATOMIZER_ROOT, "studies") # Debug: print the resolved path at module load print(f"[Terminal] ATOMIZER_ROOT resolved to: {ATOMIZER_ROOT}") def resolve_study_path(study_id: str) -> str: """Find study folder by scanning topic directories. Returns relative path from ATOMIZER_ROOT (e.g., 'studies/M1_Mirror/m1_mirror_adaptive_V14'). """ # First check direct path (flat structure) direct_path = os.path.join(STUDIES_DIR, study_id) if os.path.isdir(direct_path): setup_dir = os.path.join(direct_path, "1_setup") config_file = os.path.join(direct_path, "optimization_config.json") if os.path.exists(setup_dir) or os.path.exists(config_file): return f"studies/{study_id}" # Scan topic folders for nested structure if os.path.isdir(STUDIES_DIR): for topic_name in os.listdir(STUDIES_DIR): topic_path = os.path.join(STUDIES_DIR, topic_name) if os.path.isdir(topic_path) and not topic_name.startswith('.'): study_path = os.path.join(topic_path, study_id) if os.path.isdir(study_path): setup_dir = os.path.join(study_path, "1_setup") config_file = os.path.join(study_path, "optimization_config.json") if os.path.exists(setup_dir) or os.path.exists(config_file): return f"studies/{topic_name}/{study_id}" # Fallback to flat path return f"studies/{study_id}" def get_session_prompt(study_name: str = None) -> str: """ Generate the initial prompt for a Claude session. This injects the Protocol Operating System context and study-specific info. """ prompt_lines = [ "# Atomizer Session Context", "", "You are assisting with **Atomizer** - an LLM-first FEA optimization framework.", "", "## Bootstrap (READ FIRST)", "", "Read these files to understand how to help:", "- `.claude/skills/00_BOOTSTRAP.md` - Task classification and routing", "- `.claude/skills/01_CHEATSHEET.md` - Quick reference (I want X → Use Y)", "- `.claude/skills/02_CONTEXT_LOADER.md` - What to load per task", "", "## Protocol System", "", "| Layer | Location | Purpose |", "|-------|----------|---------|", "| Operations | `docs/protocols/operations/OP_*.md` | How-to guides |", "| System | `docs/protocols/system/SYS_*.md` | Core specs |", "| Extensions | `docs/protocols/extensions/EXT_*.md` | Adding features |", "", ] if study_name: # Resolve actual study path (handles nested folder structure) study_path = resolve_study_path(study_name) prompt_lines.extend([ f"## Current Study: `{study_name}`", "", f"**Directory**: `{study_path}/`", "", "Key files:", f"- `{study_path}/1_setup/optimization_config.json` - Configuration", f"- `{study_path}/3_results/study.db` - Optuna database", f"- `{study_path}/README.md` - Study documentation", "", "Quick status check:", "```bash", f"python -c \"import optuna; s=optuna.load_study('{study_name}', 'sqlite:///{study_path}/3_results/study.db'); print(f'Trials: {{len(s.trials)}}, Best: {{s.best_value}}')\"", "```", "", ]) else: prompt_lines.extend([ "## No Study Selected", "", "No specific study context. You can:", "- List studies: `ls studies/`", "- Create new study: Ask user what they want to optimize", "- Load context: Read `.claude/skills/core/study-creation-core.md`", "", ]) prompt_lines.extend([ "## Key Principles", "", "1. **Read bootstrap first** - Follow task routing from 00_BOOTSTRAP.md", "2. **Use centralized extractors** - Check `optimization_engine/extractors/`", "3. **Never modify master models** - Work on copies", "4. **Python env**: Always use `conda activate atomizer`", "", "---", "*Session launched from Atomizer Dashboard*", ]) return "\n".join(prompt_lines) # Check if winpty is available (for Windows) try: from winpty import PtyProcess HAS_WINPTY = True print("[Terminal] winpty is available") except ImportError as e: HAS_WINPTY = False print(f"[Terminal] winpty not available: {e}") class TerminalSession: """Manages a Claude Code terminal session.""" def __init__(self, session_id: str, working_dir: str): self.session_id = session_id self.working_dir = working_dir self.process = None self.websocket: Optional[WebSocket] = None self._read_task: Optional[asyncio.Task] = None self._running = False self._use_winpty = sys.platform == "win32" and HAS_WINPTY async def start(self, websocket: WebSocket): """Start the Claude Code process.""" self.websocket = websocket self._running = True # Validate working directory exists if not os.path.isdir(self.working_dir): await self.websocket.send_json({ "type": "error", "message": f"Working directory does not exist: {self.working_dir}" }) self._running = False return try: if self._use_winpty: # Use winpty for proper PTY on Windows # Spawn claude directly - winpty handles the interactive terminal import shutil claude_path = shutil.which("claude") or "claude" # Ensure HOME and USERPROFILE are properly set for Claude CLI auth env = {**os.environ} env["FORCE_COLOR"] = "1" env["TERM"] = "xterm-256color" # Claude CLI looks for credentials in HOME/.claude or USERPROFILE/.claude # Ensure these are set correctly if "USERPROFILE" in env and "HOME" not in env: env["HOME"] = env["USERPROFILE"] self.process = PtyProcess.spawn( claude_path, cwd=self.working_dir, env=env ) elif sys.platform == "win32": # Fallback: Windows without winpty - use subprocess import shutil claude_cmd = shutil.which("claude") or "claude" self.process = subprocess.Popen( ["cmd.exe", "/k", claude_cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.working_dir, bufsize=0, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, env={**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"} ) else: # On Unix, use pty import pty import shutil claude_cmd = shutil.which("claude") or "claude" master_fd, slave_fd = pty.openpty() self.process = subprocess.Popen( [claude_cmd], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, cwd=self.working_dir, env={**os.environ, "TERM": "xterm-256color"} ) os.close(slave_fd) self._master_fd = master_fd # Start reading output self._read_task = asyncio.create_task(self._read_output()) await self.websocket.send_json({ "type": "started", "message": f"Claude Code started in {self.working_dir}" }) except FileNotFoundError: await self.websocket.send_json({ "type": "error", "message": "Claude Code CLI not found. Please install it with: npm install -g @anthropic-ai/claude-code" }) self._running = False except Exception as e: await self.websocket.send_json({ "type": "error", "message": f"Failed to start Claude Code: {str(e)}" }) self._running = False async def _read_output(self): """Read output from the process and send to WebSocket.""" try: while self._running: if self._use_winpty: # Read from winpty if self.process and self.process.isalive(): loop = asyncio.get_event_loop() try: data = await loop.run_in_executor( None, lambda: self.process.read(4096) ) if data: await self.websocket.send_json({ "type": "output", "data": data }) except Exception: break else: break elif sys.platform == "win32": # Windows subprocess pipe mode if self.process and self.process.poll() is None: if self.process.stdout: loop = asyncio.get_event_loop() try: # Use non-blocking read with a timeout import msvcrt import ctypes # Read available data data = await loop.run_in_executor( None, lambda: self.process.stdout.read(1) ) if data: # Read more if available more_data = b"" try: # Try to read more without blocking while True: extra = self.process.stdout.read(1) if extra: more_data += extra else: break except: pass full_data = data + more_data await self.websocket.send_json({ "type": "output", "data": full_data.decode("utf-8", errors="replace") }) except Exception: break else: break else: # Unix PTY loop = asyncio.get_event_loop() try: data = await loop.run_in_executor( None, lambda: os.read(self._master_fd, 4096) ) if data: await self.websocket.send_json({ "type": "output", "data": data.decode("utf-8", errors="replace") }) except OSError: break await asyncio.sleep(0.01) # Process ended if self.websocket: exit_code = -1 if self._use_winpty: exit_code = self.process.exitstatus if self.process else -1 elif self.process: exit_code = self.process.poll() if self.process.poll() is not None else -1 await self.websocket.send_json({ "type": "exit", "code": exit_code }) except Exception as e: if self.websocket: try: await self.websocket.send_json({ "type": "error", "message": str(e) }) except: pass async def write(self, data: str): """Write input to the process.""" if not self.process or not self._running: return try: if self._use_winpty: self.process.write(data) elif sys.platform == "win32": if self.process.stdin: self.process.stdin.write(data.encode()) self.process.stdin.flush() else: os.write(self._master_fd, data.encode()) except Exception as e: if self.websocket: await self.websocket.send_json({ "type": "error", "message": f"Write error: {str(e)}" }) async def resize(self, cols: int, rows: int): """Resize the terminal.""" if self._use_winpty and self.process: try: self.process.setwinsize(rows, cols) except: pass elif sys.platform != "win32" and hasattr(self, '_master_fd'): import struct import fcntl import termios winsize = struct.pack("HHHH", rows, cols, 0, 0) fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) async def stop(self): """Stop the terminal session.""" self._running = False if self._read_task: self._read_task.cancel() try: await self._read_task except asyncio.CancelledError: pass if self.process: try: if self._use_winpty: self.process.terminate() elif sys.platform == "win32": self.process.terminate() else: os.kill(self.process.pid, signal.SIGTERM) self.process.wait(timeout=2) except: try: if hasattr(self.process, 'kill'): self.process.kill() except: pass if sys.platform != "win32" and hasattr(self, '_master_fd'): try: os.close(self._master_fd) except: pass @router.websocket("/claude") async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_id: str = None): """ WebSocket endpoint for Claude Code terminal. Query params: working_dir: Directory to start Claude Code in (defaults to Atomizer root) study_id: Optional study ID to set context for Claude Client -> Server messages: {"type": "input", "data": "user input text"} {"type": "resize", "cols": 80, "rows": 24} {"type": "stop"} Server -> Client messages: {"type": "started", "message": "..."} {"type": "output", "data": "terminal output"} {"type": "exit", "code": 0} {"type": "error", "message": "..."} {"type": "context", "prompt": "..."} # Initial context prompt """ await websocket.accept() # Default to Atomizer root directory if not working_dir: working_dir = ATOMIZER_ROOT # Create session session_id = f"claude-{id(websocket)}" session = TerminalSession(session_id, working_dir) _terminal_sessions[session_id] = session try: # Send context prompt to frontend (for display/reference) context_prompt = get_session_prompt(study_id) await websocket.send_json({ "type": "context", "prompt": context_prompt, "study_id": study_id }) # Start Claude Code await session.start(websocket) # If study_id provided, send initial context to Claude after startup if study_id: # Wait a moment for Claude to initialize await asyncio.sleep(1.0) # Send the context as the first message initial_message = f"I'm working with the Atomizer study '{study_id}'. Please read .claude/skills/00_BOOTSTRAP.md first to understand the Protocol Operating System, then help me with this study.\n" await session.write(initial_message) # Handle incoming messages while session._running: try: message = await websocket.receive_json() if message.get("type") == "input": await session.write(message.get("data", "")) elif message.get("type") == "resize": await session.resize( message.get("cols", 80), message.get("rows", 24) ) elif message.get("type") == "stop": break except WebSocketDisconnect: break except Exception as e: await websocket.send_json({ "type": "error", "message": str(e) }) finally: await session.stop() _terminal_sessions.pop(session_id, None) @router.get("/status") async def terminal_status(): """Check if Claude Code CLI is available.""" import shutil claude_path = shutil.which("claude") return { "available": claude_path is not None, "path": claude_path, "winpty_available": HAS_WINPTY, "message": "Claude Code CLI is available" if claude_path else "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code" } @router.get("/context") async def get_context(study_id: str = None): """ Get the context prompt for a Claude session without starting a terminal. Useful for displaying context in the UI or preparing prompts. Query params: study_id: Optional study ID to include study-specific context """ prompt = get_session_prompt(study_id) # Resolve study path for nested folder structure study_path = resolve_study_path(study_id) if study_id else None return { "study_id": study_id, "prompt": prompt, "bootstrap_files": [ ".claude/skills/00_BOOTSTRAP.md", ".claude/skills/01_CHEATSHEET.md", ".claude/skills/02_CONTEXT_LOADER.md", ], "study_files": [ f"{study_path}/1_setup/optimization_config.json", f"{study_path}/3_results/study.db", f"{study_path}/README.md", ] if study_path else [] }