Dashboard: - Enhanced terminal components (ClaudeTerminal, GlobalClaudeTerminal) - Improved MarkdownRenderer for better documentation display - Updated convergence plots (ConvergencePlot, PlotlyConvergencePlot) - Refined Home, Analysis, Dashboard, Setup, Results pages - Added StudyContext improvements - Updated vite.config for better dev experience Configuration: - Updated CLAUDE.md with latest instructions - Enhanced launch_dashboard.py - Updated config.py settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
537 lines
20 KiB
Python
537 lines
20 KiB
Python
"""
|
|
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 []
|
|
}
|