From 45ba266f71b2dcdf35fe61b27cb63c6e4ba3f0a5 Mon Sep 17 00:00:00 2001 From: Auto Date: Wed, 7 Jan 2026 12:29:07 +0200 Subject: [PATCH 001/265] feat: Add global settings modal and simplify agent controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a settings system for global configuration with YOLO mode toggle and model selection. Simplifies the agent control UI by removing redundant status indicator and pause functionality. ## Settings System - New SettingsModal with YOLO mode toggle and model selection - Settings persisted in SQLite (registry.db) - shared across all projects - Models fetched from API endpoint (/api/settings/models) - Single source of truth for models in registry.py - easy to add new models - Optimistic UI updates with rollback on error ## Agent Control Simplification - Removed StatusIndicator ("STOPPED"/"RUNNING" label) - redundant - Removed Pause/Resume buttons - just Start/Stop toggle now - Start button shows flame icon with fiery gradient when YOLO mode enabled ## Code Review Fixes - Added focus trap to SettingsModal for accessibility - Fixed YOLO button color contrast (WCAG AA compliance) - Added model validation to AgentStartRequest schema - Added model to AgentStatus response - Added aria-labels to all icon-only buttons - Added role="radiogroup" to model selection - Added loading indicator during settings save - Added SQLite timeout (30s) and retry logic with exponential backoff - Added thread-safe database engine initialization - Added orphaned lock file cleanup on server startup ## Files Changed - registry.py: Model config, Settings CRUD, SQLite improvements - server/routers/settings.py: New settings API - server/schemas.py: Settings schemas with validation - server/services/process_manager.py: Model param, orphan cleanup - ui/src/components/SettingsModal.tsx: New modal component - ui/src/components/AgentControl.tsx: Simplified to Start/Stop only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- autonomous_agent_demo.py | 6 +- registry.py | 168 ++++++++++++++++++++-- server/main.py | 7 +- server/routers/__init__.py | 2 + server/routers/agent.py | 23 ++- server/routers/settings.py | 75 ++++++++++ server/schemas.py | 59 +++++++- server/services/process_manager.py | 83 ++++++++++- ui/src/App.tsx | 33 ++++- ui/src/components/AgentControl.tsx | 190 ++++++------------------- ui/src/components/SettingsModal.tsx | 213 ++++++++++++++++++++++++++++ ui/src/hooks/useProjects.ts | 73 +++++++++- ui/src/lib/api.ts | 22 +++ ui/src/lib/types.ts | 25 ++++ ui/src/styles/globals.css | 17 +++ ui/tsconfig.tsbuildinfo | 2 +- 16 files changed, 825 insertions(+), 173 deletions(-) create mode 100644 server/routers/settings.py create mode 100644 ui/src/components/SettingsModal.tsx diff --git a/autonomous_agent_demo.py b/autonomous_agent_demo.py index f240cc28..71151cba 100644 --- a/autonomous_agent_demo.py +++ b/autonomous_agent_demo.py @@ -32,11 +32,7 @@ load_dotenv() from agent import run_autonomous_agent -from registry import get_project_path - -# Configuration -# DEFAULT_MODEL = "claude-sonnet-4-5-20250929" -DEFAULT_MODEL = "claude-opus-4-5-20251101" +from registry import DEFAULT_MODEL, get_project_path def parse_args() -> argparse.Namespace: diff --git a/registry.py b/registry.py index 5d48a1c8..20d31dfc 100644 --- a/registry.py +++ b/registry.py @@ -9,6 +9,8 @@ import logging import os import re +import threading +import time from contextlib import contextmanager from datetime import datetime from pathlib import Path @@ -22,6 +24,29 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Model Configuration (Single Source of Truth) +# ============================================================================= + +# Available models with display names +# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"} +AVAILABLE_MODELS = [ + {"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"}, + {"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"}, +] + +# List of valid model IDs (derived from AVAILABLE_MODELS) +VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS] + +# Default model and settings +DEFAULT_MODEL = "claude-opus-4-5-20251101" +DEFAULT_YOLO_MODE = False + +# SQLite connection settings +SQLITE_TIMEOUT = 30 # seconds to wait for database lock +SQLITE_MAX_RETRIES = 3 # number of retry attempts on busy database + + # ============================================================================= # Exceptions # ============================================================================= @@ -62,13 +87,23 @@ class Project(Base): created_at = Column(DateTime, nullable=False) +class Settings(Base): + """SQLAlchemy model for global settings (key-value store).""" + __tablename__ = "settings" + + key = Column(String(50), primary_key=True) + value = Column(String(500), nullable=False) + updated_at = Column(DateTime, nullable=False) + + # ============================================================================= # Database Connection # ============================================================================= -# Module-level singleton for database engine +# Module-level singleton for database engine with thread-safe initialization _engine = None _SessionLocal = None +_engine_lock = threading.Lock() def get_config_dir() -> Path: @@ -90,20 +125,29 @@ def get_registry_path() -> Path: def _get_engine(): """ - Get or create the database engine (singleton pattern). + Get or create the database engine (thread-safe singleton pattern). Returns: Tuple of (engine, SessionLocal) """ global _engine, _SessionLocal + # Double-checked locking for thread safety if _engine is None: - db_path = get_registry_path() - db_url = f"sqlite:///{db_path.as_posix()}" - _engine = create_engine(db_url, connect_args={"check_same_thread": False}) - Base.metadata.create_all(bind=_engine) - _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) - logger.debug("Initialized registry database at: %s", db_path) + with _engine_lock: + if _engine is None: + db_path = get_registry_path() + db_url = f"sqlite:///{db_path.as_posix()}" + _engine = create_engine( + db_url, + connect_args={ + "check_same_thread": False, + "timeout": SQLITE_TIMEOUT, + } + ) + Base.metadata.create_all(bind=_engine) + _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) + logger.debug("Initialized registry database at: %s", db_path) return _engine, _SessionLocal @@ -113,6 +157,8 @@ def _get_session(): """ Context manager for database sessions with automatic commit/rollback. + Includes retry logic for SQLite busy database errors. + Yields: SQLAlchemy session """ @@ -128,6 +174,40 @@ def _get_session(): session.close() +def _with_retry(func, *args, **kwargs): + """ + Execute a database operation with retry logic for busy database. + + Args: + func: Function to execute + *args, **kwargs: Arguments to pass to the function + + Returns: + Result of the function + + Raises: + Last exception if all retries fail + """ + last_error = None + for attempt in range(SQLITE_MAX_RETRIES): + try: + return func(*args, **kwargs) + except Exception as e: + last_error = e + error_str = str(e).lower() + if "database is locked" in error_str or "sqlite_busy" in error_str: + if attempt < SQLITE_MAX_RETRIES - 1: + wait_time = (2 ** attempt) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s + logger.warning( + "Database busy, retrying in %.1fs (attempt %d/%d)", + wait_time, attempt + 1, SQLITE_MAX_RETRIES + ) + time.sleep(wait_time) + continue + raise + raise last_error + + # ============================================================================= # Project CRUD Functions # ============================================================================= @@ -364,3 +444,75 @@ def list_valid_projects() -> list[dict[str, Any]]: return valid finally: session.close() + + +# ============================================================================= +# Settings CRUD Functions +# ============================================================================= + +def get_setting(key: str, default: str | None = None) -> str | None: + """ + Get a setting value by key. + + Args: + key: The setting key. + default: Default value if setting doesn't exist or on DB error. + + Returns: + The setting value, or default if not found or on error. + """ + try: + _, SessionLocal = _get_engine() + session = SessionLocal() + try: + setting = session.query(Settings).filter(Settings.key == key).first() + return setting.value if setting else default + finally: + session.close() + except Exception as e: + logger.warning("Failed to read setting '%s': %s", key, e) + return default + + +def set_setting(key: str, value: str) -> None: + """ + Set a setting value (creates or updates). + + Args: + key: The setting key. + value: The setting value. + """ + with _get_session() as session: + setting = session.query(Settings).filter(Settings.key == key).first() + if setting: + setting.value = value + setting.updated_at = datetime.now() + else: + setting = Settings( + key=key, + value=value, + updated_at=datetime.now() + ) + session.add(setting) + + logger.debug("Set setting '%s' = '%s'", key, value) + + +def get_all_settings() -> dict[str, str]: + """ + Get all settings as a dictionary. + + Returns: + Dictionary mapping setting keys to values. + """ + try: + _, SessionLocal = _get_engine() + session = SessionLocal() + try: + settings = session.query(Settings).all() + return {s.key: s.value for s in settings} + finally: + session.close() + except Exception as e: + logger.warning("Failed to read settings: %s", e) + return {} diff --git a/server/main.py b/server/main.py index f48e9f2e..72c7e730 100644 --- a/server/main.py +++ b/server/main.py @@ -21,11 +21,12 @@ features_router, filesystem_router, projects_router, + settings_router, spec_creation_router, ) from .schemas import SetupStatus from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions -from .services.process_manager import cleanup_all_managers +from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks from .websocket import project_websocket # Paths @@ -36,7 +37,8 @@ @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown.""" - # Startup + # Startup - clean up orphaned lock files from previous runs + cleanup_orphaned_locks() yield # Shutdown - cleanup all running agents and assistant sessions await cleanup_all_managers() @@ -92,6 +94,7 @@ async def require_localhost(request: Request, call_next): app.include_router(spec_creation_router) app.include_router(filesystem_router) app.include_router(assistant_chat_router) +app.include_router(settings_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 48b4f804..f39f8b9d 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -10,6 +10,7 @@ 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 __all__ = [ @@ -19,4 +20,5 @@ "spec_creation_router", "filesystem_router", "assistant_chat_router", + "settings_router", ] diff --git a/server/routers/agent.py b/server/routers/agent.py index d5631fa7..309ab1c2 100644 --- a/server/routers/agent.py +++ b/server/routers/agent.py @@ -26,6 +26,21 @@ def _get_project_path(project_name: str) -> Path: return get_project_path(project_name) +def _get_settings_defaults() -> tuple[bool, str]: + """Get YOLO mode and model defaults from global settings.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import DEFAULT_MODEL, get_all_settings + + settings = get_all_settings() + yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true" + model = settings.get("model", DEFAULT_MODEL) + return yolo_mode, model + + router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"]) # Root directory for process manager @@ -69,6 +84,7 @@ async def get_agent_status(project_name: str): pid=manager.pid, started_at=manager.started_at, yolo_mode=manager.yolo_mode, + model=manager.model, ) @@ -80,7 +96,12 @@ async def start_agent( """Start the agent for a project.""" manager = get_project_manager(project_name) - success, message = await manager.start(yolo_mode=request.yolo_mode) + # Get defaults from global settings if not provided in request + default_yolo, default_model = _get_settings_defaults() + yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo + model = request.model if request.model else default_model + + success, message = await manager.start(yolo_mode=yolo_mode, model=model) return AgentActionResponse( success=success, diff --git a/server/routers/settings.py b/server/routers/settings.py new file mode 100644 index 00000000..10b0fa32 --- /dev/null +++ b/server/routers/settings.py @@ -0,0 +1,75 @@ +""" +Settings Router +=============== + +API endpoints for global settings management. +Settings are stored in the registry database and shared across all projects. +""" + +import sys +from pathlib import Path + +from fastapi import APIRouter + +from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate + +# Add root to path for registry import +ROOT_DIR = Path(__file__).parent.parent.parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from registry import ( + AVAILABLE_MODELS, + DEFAULT_MODEL, + DEFAULT_YOLO_MODE, + get_all_settings, + set_setting, +) + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +def _parse_yolo_mode(value: str | None) -> bool: + """Parse YOLO mode string to boolean.""" + return (value or "false").lower() == "true" + + +@router.get("/models", response_model=ModelsResponse) +async def get_available_models(): + """Get list of available models. + + Frontend should call this to get the current list of models + instead of hardcoding them. + """ + return ModelsResponse( + models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS], + default=DEFAULT_MODEL, + ) + + +@router.get("", response_model=SettingsResponse) +async def get_settings(): + """Get current global settings.""" + all_settings = get_all_settings() + + return SettingsResponse( + yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), + model=all_settings.get("model", DEFAULT_MODEL), + ) + + +@router.patch("", response_model=SettingsResponse) +async def update_settings(update: SettingsUpdate): + """Update global settings.""" + if update.yolo_mode is not None: + set_setting("yolo_mode", "true" if update.yolo_mode else "false") + + if update.model is not None: + set_setting("model", update.model) + + # Return updated settings + all_settings = get_all_settings() + return SettingsResponse( + yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), + model=all_settings.get("model", DEFAULT_MODEL), + ) diff --git a/server/schemas.py b/server/schemas.py index 5531a448..842906a8 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -6,11 +6,20 @@ """ import base64 +import sys from datetime import datetime +from pathlib import Path from typing import Literal from pydantic import BaseModel, Field, field_validator +# Import model constants from registry (single source of truth) +_root = Path(__file__).parent.parent +if str(_root) not in sys.path: + sys.path.insert(0, str(_root)) + +from registry import AVAILABLE_MODELS, DEFAULT_MODEL, VALID_MODELS + # ============================================================================ # Project Schemas # ============================================================================ @@ -102,7 +111,16 @@ class FeatureListResponse(BaseModel): class AgentStartRequest(BaseModel): """Request schema for starting the agent.""" - yolo_mode: bool = False + yolo_mode: bool | None = None # None means use global settings + model: str | None = None # None means use global settings + + @field_validator('model') + @classmethod + def validate_model(cls, v: str | None) -> str | None: + """Validate model is in the allowed list.""" + if v is not None and v not in VALID_MODELS: + raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") + return v class AgentStatus(BaseModel): @@ -111,6 +129,7 @@ class AgentStatus(BaseModel): pid: int | None = None started_at: datetime | None = None yolo_mode: bool = False + model: str | None = None # Model being used by running agent class AgentActionResponse(BaseModel): @@ -239,3 +258,41 @@ class CreateDirectoryRequest(BaseModel): """Request to create a new directory.""" parent_path: str name: str = Field(..., min_length=1, max_length=255) + + +# ============================================================================ +# Settings Schemas +# ============================================================================ + +# Note: VALID_MODELS and DEFAULT_MODEL are imported from registry at the top of this file + + +class ModelInfo(BaseModel): + """Information about an available model.""" + id: str + name: str + + +class SettingsResponse(BaseModel): + """Response schema for global settings.""" + yolo_mode: bool = False + model: str = DEFAULT_MODEL + + +class ModelsResponse(BaseModel): + """Response schema for available models list.""" + models: list[ModelInfo] + default: str + + +class SettingsUpdate(BaseModel): + """Request schema for updating global settings.""" + yolo_mode: bool | None = None + model: str | None = None + + @field_validator('model') + @classmethod + def validate_model(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_MODELS: + raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") + return v diff --git a/server/services/process_manager.py b/server/services/process_manager.py index d2b4f0b3..88ec2bde 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -74,6 +74,7 @@ def __init__( self.started_at: datetime | None = None self._output_task: asyncio.Task | None = None self.yolo_mode: bool = False # YOLO mode for rapid prototyping + self.model: str | None = None # Model being used # Support multiple callbacks (for multiple WebSocket clients) self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set() @@ -214,12 +215,13 @@ async def _stream_output(self) -> None: self.status = "stopped" self._remove_lock() - async def start(self, yolo_mode: bool = False) -> tuple[bool, str]: + async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]: """ Start the agent as a subprocess. Args: yolo_mode: If True, run in YOLO mode (no browser testing) + model: Model to use (e.g., claude-opus-4-5-20251101) Returns: Tuple of (success, message) @@ -230,8 +232,9 @@ async def start(self, yolo_mode: bool = False) -> tuple[bool, str]: if not self._check_lock(): return False, "Another agent instance is already running for this project" - # Store YOLO mode for status queries + # Store for status queries self.yolo_mode = yolo_mode + self.model = model # Build command - pass absolute path to project directory cmd = [ @@ -241,6 +244,10 @@ async def start(self, yolo_mode: bool = False) -> tuple[bool, str]: str(self.project_dir.resolve()), ] + # Add --model flag if model is specified + if model: + cmd.extend(["--model", model]) + # Add --yolo flag if YOLO mode is enabled if yolo_mode: cmd.append("--yolo") @@ -306,6 +313,7 @@ async def stop(self) -> tuple[bool, str]: self.process = None self.started_at = None self.yolo_mode = False # Reset YOLO mode + self.model = None # Reset model return True, "Agent stopped" except Exception as e: @@ -387,6 +395,7 @@ def get_status_dict(self) -> dict: "pid": self.pid, "started_at": self.started_at.isoformat() if self.started_at else None, "yolo_mode": self.yolo_mode, + "model": self.model, } @@ -423,3 +432,73 @@ async def cleanup_all_managers() -> None: with _managers_lock: _managers.clear() + + +def cleanup_orphaned_locks() -> int: + """ + Clean up orphaned lock files from previous server runs. + + Scans all registered projects for .agent.lock files and removes them + if the referenced process is no longer running. + + Returns: + Number of orphaned lock files cleaned up + """ + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import list_registered_projects + + cleaned = 0 + try: + projects = list_registered_projects() + for name, info in projects.items(): + project_path = Path(info.get("path", "")) + if not project_path.exists(): + continue + + lock_file = project_path / ".agent.lock" + if not lock_file.exists(): + continue + + try: + pid_str = lock_file.read_text().strip() + pid = int(pid_str) + + # Check if process is still running + if psutil.pid_exists(pid): + try: + proc = psutil.Process(pid) + cmdline = " ".join(proc.cmdline()) + if "autonomous_agent_demo.py" in cmdline: + # Process is still running, don't remove + logger.info( + "Found running agent for project '%s' (PID %d)", + name, pid + ) + continue + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Process not running or not our agent - remove stale lock + lock_file.unlink(missing_ok=True) + cleaned += 1 + logger.info("Removed orphaned lock file for project '%s'", name) + + except (ValueError, OSError) as e: + # Invalid lock file content - remove it + logger.warning( + "Removing invalid lock file for project '%s': %s", name, e + ) + lock_file.unlink(missing_ok=True) + cleaned += 1 + + except Exception as e: + logger.error("Error during orphan cleanup: %s", e) + + if cleaned: + logger.info("Cleaned up %d orphaned lock file(s)", cleaned) + + return cleaned diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 794c5a2e..9aecad9f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,7 +16,8 @@ import { DebugLogViewer } from './components/DebugLogViewer' import { AgentThought } from './components/AgentThought' import { AssistantFAB } from './components/AssistantFAB' import { AssistantPanel } from './components/AssistantPanel' -import { Plus, Loader2 } from 'lucide-react' +import { SettingsModal } from './components/SettingsModal' +import { Plus, Loader2, Settings } from 'lucide-react' import type { Feature } from './lib/types' function App() { @@ -34,10 +35,11 @@ function App() { const [debugOpen, setDebugOpen] = useState(false) const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height const [assistantOpen, setAssistantOpen] = useState(false) + const [showSettings, setShowSettings] = useState(false) const { data: projects, isLoading: projectsLoading } = useProjects() const { data: features } = useFeatures(selectedProject) - const { data: agentStatusData } = useAgentStatus(selectedProject) + useAgentStatus(selectedProject) // Keep polling for status updates const wsState = useProjectWebSocket(selectedProject) // Play sounds when features move between columns @@ -93,9 +95,17 @@ function App() { setAssistantOpen(prev => !prev) } + // , : Open settings + if (e.key === ',') { + e.preventDefault() + setShowSettings(true) + } + // Escape : Close modals if (e.key === 'Escape') { - if (assistantOpen) { + if (showSettings) { + setShowSettings(false) + } else if (assistantOpen) { setAssistantOpen(false) } else if (showAddFeature) { setShowAddFeature(false) @@ -109,7 +119,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen]) + }, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen, showSettings]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -163,8 +173,16 @@ function App() { + + )} @@ -270,6 +288,11 @@ function App() { /> )} + + {/* Settings Modal */} + {showSettings && ( + setShowSettings(false)} /> + )} ) } diff --git a/ui/src/components/AgentControl.tsx b/ui/src/components/AgentControl.tsx index 1b94fddd..54840591 100644 --- a/ui/src/components/AgentControl.tsx +++ b/ui/src/components/AgentControl.tsx @@ -1,170 +1,66 @@ -import { useState } from 'react' -import { Play, Pause, Square, Loader2, Zap } from 'lucide-react' +import { Play, Square, Loader2, Flame } from 'lucide-react' import { useStartAgent, useStopAgent, - usePauseAgent, - useResumeAgent, + useSettings, } from '../hooks/useProjects' import type { AgentStatus } from '../lib/types' interface AgentControlProps { projectName: string status: AgentStatus - yoloMode?: boolean // From server status - whether currently running in YOLO mode } -export function AgentControl({ projectName, status, yoloMode = false }: AgentControlProps) { - const [yoloEnabled, setYoloEnabled] = useState(false) +export function AgentControl({ projectName, status }: AgentControlProps) { + const { data: settings } = useSettings() + const yoloMode = settings?.yolo_mode ?? false const startAgent = useStartAgent(projectName) const stopAgent = useStopAgent(projectName) - const pauseAgent = usePauseAgent(projectName) - const resumeAgent = useResumeAgent(projectName) - const isLoading = - startAgent.isPending || - stopAgent.isPending || - pauseAgent.isPending || - resumeAgent.isPending + const isLoading = startAgent.isPending || stopAgent.isPending - const handleStart = () => startAgent.mutate(yoloEnabled) + const handleStart = () => startAgent.mutate(yoloMode) const handleStop = () => stopAgent.mutate() - const handlePause = () => pauseAgent.mutate() - const handleResume = () => resumeAgent.mutate() - return ( -
- {/* Status Indicator */} - - - {/* YOLO Mode Indicator - shown when running in YOLO mode */} - {(status === 'running' || status === 'paused') && yoloMode && ( -
- - - YOLO - -
- )} - - {/* Control Buttons */} -
- {status === 'stopped' || status === 'crashed' ? ( - <> - {/* YOLO Toggle - only shown when stopped */} - - - - ) : status === 'running' ? ( - <> - - - - ) : status === 'paused' ? ( - <> - - - - ) : null} -
-
- ) -} - -function StatusIndicator({ status }: { status: AgentStatus }) { - const statusConfig = { - stopped: { - color: 'var(--color-neo-text-secondary)', - label: 'Stopped', - pulse: false, - }, - running: { - color: 'var(--color-neo-done)', - label: 'Running', - pulse: true, - }, - paused: { - color: 'var(--color-neo-pending)', - label: 'Paused', - pulse: false, - }, - crashed: { - color: 'var(--color-neo-danger)', - label: 'Crashed', - pulse: true, - }, - } - - const config = statusConfig[status] + // Simplified: either show Start (when stopped/crashed) or Stop (when running/paused) + const isStopped = status === 'stopped' || status === 'crashed' return ( -
- - - {config.label} - +
+ {isStopped ? ( + + ) : ( + + )}
) } diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx new file mode 100644 index 00000000..11608a73 --- /dev/null +++ b/ui/src/components/SettingsModal.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef } from 'react' +import { X, Loader2, AlertCircle } from 'lucide-react' +import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects' + +interface SettingsModalProps { + onClose: () => void +} + +export function SettingsModal({ onClose }: SettingsModalProps) { + const { data: settings, isLoading, isError, refetch } = useSettings() + const { data: modelsData } = useAvailableModels() + const updateSettings = useUpdateSettings() + const modalRef = useRef(null) + const closeButtonRef = useRef(null) + + // Focus trap - keep focus within modal + useEffect(() => { + const modal = modalRef.current + if (!modal) return + + // Focus the close button when modal opens + closeButtonRef.current?.focus() + + const focusableElements = modal.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + const firstElement = focusableElements[0] + const lastElement = focusableElements[focusableElements.length - 1] + + const handleTabKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault() + lastElement?.focus() + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault() + firstElement?.focus() + } + } + } + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleTabKey) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('keydown', handleTabKey) + document.removeEventListener('keydown', handleEscape) + } + }, [onClose]) + + const handleYoloToggle = () => { + if (settings && !updateSettings.isPending) { + updateSettings.mutate({ yolo_mode: !settings.yolo_mode }) + } + } + + const handleModelChange = (modelId: string) => { + if (!updateSettings.isPending) { + updateSettings.mutate({ model: modelId }) + } + } + + const models = modelsData?.models ?? [] + const isSaving = updateSettings.isPending + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-labelledby="settings-title" + aria-modal="true" + > + {/* Header */} +
+

+ Settings + {isSaving && ( + + )} +

+ +
+ + {/* Loading State */} + {isLoading && ( +
+ + Loading settings... +
+ )} + + {/* Error State */} + {isError && ( +
+
+ + Failed to load settings +
+ +
+ )} + + {/* Settings Content */} + {settings && !isLoading && ( +
+ {/* YOLO Mode Toggle */} +
+
+
+ +

+ Skip testing for rapid prototyping +

+
+ +
+
+ + {/* Model Selection - Radio Group */} +
+ +
+ {models.map((model) => ( + + ))} +
+
+ + {/* Update Error */} + {updateSettings.isError && ( +
+ Failed to save settings. Please try again. +
+ )} +
+ )} +
+
+ ) +} diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 0cb61fa5..6a1098f6 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import * as api from '../lib/api' -import type { FeatureCreate } from '../lib/types' +import type { FeatureCreate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types' // ============================================================================ // Projects @@ -200,3 +200,74 @@ export function useValidatePath() { mutationFn: (path: string) => api.validatePath(path), }) } + +// ============================================================================ +// Settings +// ============================================================================ + +// Default models response for placeholder (until API responds) +const DEFAULT_MODELS: ModelsResponse = { + models: [ + { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' }, + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, + ], + default: 'claude-opus-4-5-20251101', +} + +const DEFAULT_SETTINGS: Settings = { + yolo_mode: false, + model: 'claude-opus-4-5-20251101', +} + +export function useAvailableModels() { + return useQuery({ + queryKey: ['available-models'], + queryFn: api.getAvailableModels, + staleTime: 300000, // Cache for 5 minutes - models don't change often + retry: 1, + placeholderData: DEFAULT_MODELS, + }) +} + +export function useSettings() { + return useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + staleTime: 60000, // Cache for 1 minute + retry: 1, + placeholderData: DEFAULT_SETTINGS, + }) +} + +export function useUpdateSettings() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (settings: SettingsUpdate) => api.updateSettings(settings), + onMutate: async (newSettings) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ['settings'] }) + + // Snapshot previous value + const previous = queryClient.getQueryData(['settings']) + + // Optimistically update + queryClient.setQueryData(['settings'], (old) => ({ + ...DEFAULT_SETTINGS, + ...old, + ...newSettings, + })) + + return { previous } + }, + onError: (_err, _newSettings, context) => { + // Rollback on error + if (context?.previous) { + queryClient.setQueryData(['settings'], context.previous) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }) + }, + }) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index bfee6cc9..017d0770 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -16,6 +16,9 @@ import type { PathValidationResponse, AssistantConversation, AssistantConversationDetail, + Settings, + SettingsUpdate, + ModelsResponse, } from './types' const API_BASE = '/api' @@ -267,3 +270,22 @@ export async function deleteAssistantConversation( { method: 'DELETE' } ) } + +// ============================================================================ +// Settings API +// ============================================================================ + +export async function getAvailableModels(): Promise { + return fetchJSON('/settings/models') +} + +export async function getSettings(): Promise { + return fetchJSON('/settings') +} + +export async function updateSettings(settings: SettingsUpdate): Promise { + return fetchJSON('/settings', { + method: 'PATCH', + body: JSON.stringify(settings), + }) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c5de1958..4ca7bf20 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -90,6 +90,7 @@ export interface AgentStatusResponse { pid: number | null started_at: string | null yolo_mode: boolean + model: string | null // Model being used by running agent } export interface AgentActionResponse { @@ -295,3 +296,27 @@ export type AssistantChatServerMessage = | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage | AssistantChatPongMessage + +// ============================================================================ +// Settings Types +// ============================================================================ + +export interface ModelInfo { + id: string + name: string +} + +export interface ModelsResponse { + models: ModelInfo[] + default: string +} + +export interface Settings { + yolo_mode: boolean + model: string +} + +export interface SettingsUpdate { + yolo_mode?: boolean + model?: string +} diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index a1047a2b..c4c91292 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -163,6 +163,23 @@ transform: none; } + /* YOLO Mode Button - Fiery gradient for when YOLO mode is enabled */ + /* Uses darker orange colors for better contrast with white text (WCAG AA) */ + .neo-btn-yolo { + background: linear-gradient(135deg, #d64500, #e65c00); + color: #ffffff; + box-shadow: + 4px 4px 0 var(--color-neo-border), + 0 0 12px rgba(255, 84, 0, 0.4); + } + + .neo-btn-yolo:hover { + background: linear-gradient(135deg, #ff5400, #ff6a00); + box-shadow: + 6px 6px 0 var(--color-neo-border), + 0 0 16px rgba(255, 84, 0, 0.5); + } + /* Inputs */ .neo-input { width: 100%; diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 8bf9c84a..fd98d1fc 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file From 81dbc4bc160497d245608163bade319fc3f099cd Mon Sep 17 00:00:00 2001 From: mantarayDigital Date: Thu, 8 Jan 2026 07:19:51 +0200 Subject: [PATCH 002/265] fix: Update start.sh to use correct Claude CLI auth detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous credential check looked for ~/.claude/.credentials.json, which no longer exists in recent versions of Claude CLI. This caused the script to incorrectly prompt users to login even when they were already authenticated. Changes: - Remove check for non-existent .credentials.json file - Check for ~/.claude directory existence instead - Always remind users about 'claude login' since we can't verify auth status without making an API call - If ~/.claude doesn't exist, pause and warn (but allow continuing) - Add explanatory comments about the limitation The new approach is honest about what we can and can't verify: - We CAN check if the CLI is installed (command -v claude) - We CAN check if ~/.claude directory exists (CLI has been run) - We CANNOT verify actual auth status without an API call 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- start.sh | 40 +++++++++------------------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/start.sh b/start.sh index d90c0977..c54caee4 100644 --- a/start.sh +++ b/start.sh @@ -20,40 +20,18 @@ fi echo "[OK] Claude CLI found" -# Check if user has credentials -CLAUDE_CREDS="$HOME/.claude/.credentials.json" -if [ -f "$CLAUDE_CREDS" ]; then - echo "[OK] Claude credentials found" +# Note: Claude CLI no longer stores credentials in ~/.claude/.credentials.json +# We can't reliably check auth status without making an API call, so we just +# verify the CLI is installed and remind the user to login if needed +if [ -d "$HOME/.claude" ]; then + echo "[OK] Claude CLI directory found" + echo " (If you're not logged in, run: claude login)" else - echo "[!] Not authenticated with Claude" + echo "[!] Claude CLI not configured" echo "" - echo "You need to run 'claude login' to authenticate." - echo "This will open a browser window to sign in." + echo "Please run 'claude login' to authenticate before continuing." echo "" - read -p "Would you like to run 'claude login' now? (y/n): " LOGIN_CHOICE - - if [[ "$LOGIN_CHOICE" =~ ^[Yy]$ ]]; then - echo "" - echo "Running 'claude login'..." - echo "Complete the login in your browser, then return here." - echo "" - claude login - - # Check if login succeeded - if [ -f "$CLAUDE_CREDS" ]; then - echo "" - echo "[OK] Login successful!" - else - echo "" - echo "[ERROR] Login failed or was cancelled." - echo "Please try again." - exit 1 - fi - else - echo "" - echo "Please run 'claude login' manually, then try again." - exit 1 - fi + read -p "Press Enter to continue anyway, or Ctrl+C to exit..." fi echo "" From 780cfd343f79948f398b471c602eab9c5af4a6c5 Mon Sep 17 00:00:00 2001 From: mantarayDigital Date: Thu, 8 Jan 2026 07:30:41 +0200 Subject: [PATCH 003/265] feat: Add authentication error handling to start.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detection and helpful messaging for Claude CLI authentication errors in the Python launcher, complementing the shell script improvements. Changes: - Add is_auth_error() helper with regex patterns for common auth errors - Add print_auth_error_help() for consistent, actionable error messages - Update run_spec_creation() to capture stderr and detect auth failures - Update run_agent() to capture stderr and detect auth failures - Both functions now provide helpful "run claude login" guidance Error patterns detected: - "not logged in" / "not authenticated" - "authentication failed/required/error" - "login required" - "please run claude login" - "unauthorized" - "invalid token/credential/api key" - "expired token/session/credential" This aligns the Python UX with the shell script's non-blocking warning approach while adding proactive error detection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- start.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/start.py b/start.py index 0236122a..455bc97a 100644 --- a/start.py +++ b/start.py @@ -9,6 +9,7 @@ """ import os +import re import subprocess import sys from pathlib import Path @@ -24,6 +25,53 @@ register_project, ) +# Patterns that indicate Claude CLI authentication errors +AUTH_ERROR_PATTERNS = [ + r"not\s+logged\s+in", + r"not\s+authenticated", + r"authentication\s+(failed|required|error)", + r"login\s+required", + r"please\s+(run\s+)?['\"]?claude\s+login", + r"unauthorized", + r"invalid\s+(token|credential|api.?key)", + r"expired\s+(token|session|credential)", + r"could\s+not\s+authenticate", + r"sign\s+in\s+(to|required)", +] + + +def is_auth_error(output: str) -> bool: + """ + Check if output contains Claude CLI authentication error messages. + + Args: + output: Combined stdout/stderr from subprocess + + Returns: + True if authentication error detected, False otherwise + """ + if not output: + return False + + output_lower = output.lower() + for pattern in AUTH_ERROR_PATTERNS: + if re.search(pattern, output_lower): + return True + return False + + +def print_auth_error_help() -> None: + """Print helpful message when authentication error is detected.""" + print("\n" + "=" * 50) + print(" Authentication Error Detected") + print("=" * 50) + print("\nClaude CLI requires authentication to work.") + print("\nTo fix this, run:") + print(" claude login") + print("\nThis will open a browser window to sign in.") + print("After logging in, try running this command again.") + print("=" * 50 + "\n") + def check_spec_exists(project_dir: Path) -> bool: """ @@ -203,6 +251,7 @@ def run_spec_creation(project_dir: Path) -> bool: Run Claude Code with /create-spec command to create project specification. The project path is passed as an argument so create-spec knows where to write files. + Captures stderr to detect authentication errors and provide helpful guidance. """ print("\n" + "=" * 50) print(" Project Specification Setup") @@ -217,12 +266,25 @@ def run_spec_creation(project_dir: Path) -> bool: try: # Launch Claude Code with /create-spec command # Project path included in command string so it populates $ARGUMENTS - subprocess.run( + # Capture stderr to detect auth errors while letting stdout flow to terminal + result = subprocess.run( ["claude", f"/create-spec {project_dir}"], check=False, # Don't raise on non-zero exit - cwd=str(Path(__file__).parent) # Run from project root + cwd=str(Path(__file__).parent), # Run from project root + stderr=subprocess.PIPE, + text=True ) + # Check for authentication errors in stderr + stderr_output = result.stderr or "" + if result.returncode != 0 and is_auth_error(stderr_output): + print_auth_error_help() + return False + + # If there was stderr output but not an auth error, show it + if stderr_output.strip() and result.returncode != 0: + print(f"\nClaude CLI error: {stderr_output.strip()}") + # Check if spec was created in project prompts directory if check_spec_exists(project_dir): print("\n" + "-" * 50) @@ -232,6 +294,9 @@ def run_spec_creation(project_dir: Path) -> bool: print("\n" + "-" * 50) print("Spec creation incomplete.") print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}") + # If failed with non-zero exit and no spec, might be auth issue + if result.returncode != 0: + print("\nIf you're having authentication issues, try running: claude login") return False except FileNotFoundError: @@ -348,6 +413,8 @@ def create_new_project_flow() -> tuple[str, Path] | None: def run_agent(project_name: str, project_dir: Path) -> None: """Run the autonomous agent with the given project. + Captures stderr to detect authentication errors and provide helpful guidance. + Args: project_name: Name of the project project_dir: Absolute path to the project directory @@ -367,9 +434,28 @@ def run_agent(project_name: str, project_dir: Path) -> None: # Build the command - pass absolute path cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve())] - # Run the agent + # Run the agent with stderr capture to detect auth errors + # stdout goes directly to terminal for real-time output try: - subprocess.run(cmd, check=False) + result = subprocess.run( + cmd, + check=False, + stderr=subprocess.PIPE, + text=True + ) + + # Check for authentication errors + stderr_output = result.stderr or "" + if result.returncode != 0: + if is_auth_error(stderr_output): + print_auth_error_help() + elif stderr_output.strip(): + # Show any other errors + print(f"\nAgent error:\n{stderr_output.strip()}") + # Still hint about auth if exit was unexpected + if "error" in stderr_output.lower() or "exception" in stderr_output.lower(): + print("\nIf this is an authentication issue, try running: claude login") + except KeyboardInterrupt: print("\n\nAgent interrupted. Run again to resume.") From b2c19b0c4c5c1cd78cde454a32bcf25aa8580732 Mon Sep 17 00:00:00 2001 From: mantarayDigital Date: Thu, 8 Jan 2026 07:37:04 +0200 Subject: [PATCH 004/265] feat: Add authentication error handling to UI flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend auth error detection to the web UI flow: server/main.py: - Fix setup_status() endpoint to check ~/.claude directory instead of non-existent .credentials.json file - Add explanatory comments about Claude CLI credential storage changes server/services/process_manager.py: - Add AUTH_ERROR_PATTERNS for detecting auth errors in agent output - Add is_auth_error() helper function - Add AUTH_ERROR_HELP message template - Update _stream_output() to detect auth errors in real-time - Buffer last 20 lines to catch auth errors on process exit - Broadcast clear help message to WebSocket clients when auth fails start_ui.sh: - Add Claude CLI installation check with helpful guidance - Add ~/.claude directory check with login reminder - Non-blocking warnings that don't prevent UI from starting This ensures users get clear, actionable feedback when authentication fails, whether using the CLI or the web UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/main.py | 8 ++-- server/services/process_manager.py | 62 ++++++++++++++++++++++++++++++ start_ui.sh | 21 ++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/server/main.py b/server/main.py index f48e9f2e..5efd4afb 100644 --- a/server/main.py +++ b/server/main.py @@ -120,9 +120,11 @@ async def setup_status(): # Check for Claude CLI claude_cli = shutil.which("claude") is not None - # Check for credentials file - credentials_path = Path.home() / ".claude" / ".credentials.json" - credentials = credentials_path.exists() + # Check for Claude CLI configuration directory + # Note: Claude CLI no longer stores credentials in ~/.claude/.credentials.json + # The existence of ~/.claude indicates the CLI has been configured + claude_dir = Path.home() / ".claude" + credentials = claude_dir.exists() and claude_dir.is_dir() # Check for Node.js and npm node = shutil.which("node") is not None diff --git a/server/services/process_manager.py b/server/services/process_manager.py index d2b4f0b3..31352fc3 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -36,6 +36,47 @@ r'aws[_-]?secret[=:][^\s]+', ] +# Patterns that indicate Claude CLI authentication errors +AUTH_ERROR_PATTERNS = [ + r"not\s+logged\s+in", + r"not\s+authenticated", + r"authentication\s+(failed|required|error)", + r"login\s+required", + r"please\s+(run\s+)?['\"]?claude\s+login", + r"unauthorized", + r"invalid\s+(token|credential|api.?key)", + r"expired\s+(token|session|credential)", + r"could\s+not\s+authenticate", + r"sign\s+in\s+(to|required)", +] + + +def is_auth_error(text: str) -> bool: + """Check if text contains Claude CLI authentication error messages.""" + if not text: + return False + text_lower = text.lower() + for pattern in AUTH_ERROR_PATTERNS: + if re.search(pattern, text_lower): + return True + return False + + +AUTH_ERROR_HELP = """ +================================================================================ + AUTHENTICATION ERROR DETECTED +================================================================================ + +Claude CLI requires authentication to work. + +To fix this, run: + claude login + +This will open a browser window to sign in. +After logging in, try starting the agent again. +================================================================================ +""" + def sanitize_output(line: str) -> str: """Remove sensitive information from output lines.""" @@ -185,6 +226,9 @@ async def _stream_output(self) -> None: if not self.process or not self.process.stdout: return + auth_error_detected = False + output_buffer = [] # Buffer recent lines for auth error detection + try: loop = asyncio.get_running_loop() while True: @@ -198,6 +242,18 @@ async def _stream_output(self) -> None: decoded = line.decode("utf-8", errors="replace").rstrip() sanitized = sanitize_output(decoded) + # Buffer recent output for auth error detection + output_buffer.append(decoded) + if len(output_buffer) > 20: + output_buffer.pop(0) + + # Check for auth errors + if not auth_error_detected and is_auth_error(decoded): + auth_error_detected = True + # Broadcast auth error help message + for help_line in AUTH_ERROR_HELP.strip().split('\n'): + await self._broadcast_output(help_line) + await self._broadcast_output(sanitized) except asyncio.CancelledError: @@ -209,6 +265,12 @@ async def _stream_output(self) -> None: if self.process and self.process.poll() is not None: exit_code = self.process.returncode if exit_code != 0 and self.status == "running": + # Check buffered output for auth errors if we haven't detected one yet + if not auth_error_detected: + combined_output = '\n'.join(output_buffer) + if is_auth_error(combined_output): + for help_line in AUTH_ERROR_HELP.strip().split('\n'): + await self._broadcast_output(help_line) self.status = "crashed" elif self.status == "running": self.status = "stopped" diff --git a/start_ui.sh b/start_ui.sh index 644db747..895c4dd8 100644 --- a/start_ui.sh +++ b/start_ui.sh @@ -9,6 +9,27 @@ echo " AutoCoder UI" echo "====================================" echo "" +# Check if Claude CLI is installed +if ! command -v claude &> /dev/null; then + echo "[!] Claude CLI not found" + echo "" + echo " The agent requires Claude CLI to work." + echo " Install it from: https://claude.ai/download" + echo "" + echo " After installing, run: claude login" + echo "" +else + echo "[OK] Claude CLI found" + # Note: Claude CLI no longer stores credentials in ~/.claude/.credentials.json + # We can't reliably check auth status without making an API call + if [ -d "$HOME/.claude" ]; then + echo " (If you're not logged in, run: claude login)" + else + echo "[!] Claude CLI not configured - run 'claude login' first" + fi +fi +echo "" + # Check if Python is available if ! command -v python3 &> /dev/null; then if ! command -v python &> /dev/null; then From a195d6de08c4c450ea3d2f6c88d3b41a8ba66c57 Mon Sep 17 00:00:00 2001 From: Auto Date: Fri, 9 Jan 2026 08:08:59 +0200 Subject: [PATCH 005/265] YOLO mode effects --- ui/src/components/AgentControl.tsx | 12 +++--- ui/src/styles/globals.css | 64 +++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/ui/src/components/AgentControl.tsx b/ui/src/components/AgentControl.tsx index 54840591..1ae77b32 100644 --- a/ui/src/components/AgentControl.tsx +++ b/ui/src/components/AgentControl.tsx @@ -1,4 +1,4 @@ -import { Play, Square, Loader2, Flame } from 'lucide-react' +import { Play, Square, Loader2 } from 'lucide-react' import { useStartAgent, useStopAgent, @@ -40,8 +40,6 @@ export function AgentControl({ projectName, status }: AgentControlProps) { > {isLoading ? ( - ) : yoloMode ? ( - ) : ( )} @@ -50,9 +48,11 @@ export function AgentControl({ projectName, status }: AgentControlProps) { + {/* Expand Project - only show if project has features */} + {features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && ( + + )} + )} + {/* Expand Project Modal - AI-powered bulk feature creation */} + {showExpandProject && selectedProject && ( + setShowExpandProject(false)} + onFeaturesAdded={() => { + // Invalidate features query to refresh the kanban board + queryClient.invalidateQueries({ queryKey: ['features', selectedProject] }) + }} + /> + )} + {/* Debug Log Viewer - fixed to bottom */} {selectedProject && ( )} - {/* Assistant FAB and Panel */} - {selectedProject && ( + {/* Assistant FAB and Panel - hide FAB when expand modal is open */} + {selectedProject && !showExpandProject && ( <> setAssistantOpen(!assistantOpen)} diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx new file mode 100644 index 00000000..2d102d6e --- /dev/null +++ b/ui/src/components/ExpandProjectChat.tsx @@ -0,0 +1,375 @@ +/** + * Expand Project Chat Component + * + * Full chat interface for interactive project expansion with Claude. + * Allows users to describe new features in natural language. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react' +import { useExpandChat } from '../hooks/useExpandChat' +import { ChatMessage } from './ChatMessage' +import { TypingIndicator } from './TypingIndicator' +import type { ImageAttachment } from '../lib/types' + +// Image upload validation constants +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png'] + +interface ExpandProjectChatProps { + projectName: string + onComplete: (featuresAdded: number) => void + onCancel: () => void +} + +export function ExpandProjectChat({ + projectName, + onComplete, + onCancel, +}: ExpandProjectChatProps) { + const [input, setInput] = useState('') + const [error, setError] = useState(null) + const [pendingAttachments, setPendingAttachments] = useState([]) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + const fileInputRef = useRef(null) + + const { + messages, + isLoading, + isComplete, + connectionStatus, + featuresCreated, + start, + sendMessage, + disconnect, + } = useExpandChat({ + projectName, + onComplete, + onError: (err) => setError(err), + }) + + // Start the chat session when component mounts + useEffect(() => { + start() + + return () => { + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, isLoading]) + + // Focus input when not loading + useEffect(() => { + if (!isLoading && inputRef.current) { + inputRef.current.focus() + } + }, [isLoading]) + + const handleSendMessage = () => { + const trimmed = input.trim() + // Allow sending if there's text OR attachments + if ((!trimmed && pendingAttachments.length === 0) || isLoading) return + + sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) + setInput('') + setPendingAttachments([]) // Clear attachments after sending + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // File handling for image attachments + const handleFileSelect = useCallback((files: FileList | null) => { + if (!files) return + + Array.from(files).forEach((file) => { + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) + return + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + setError(`File too large: ${file.name}. Maximum size is 5 MB.`) + return + } + + // Read and convert to base64 + const reader = new FileReader() + reader.onload = (e) => { + const dataUrl = e.target?.result as string + const base64Data = dataUrl.split(',')[1] + + const attachment: ImageAttachment = { + id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + filename: file.name, + mimeType: file.type as 'image/jpeg' | 'image/png', + base64Data, + previewUrl: dataUrl, + size: file.size, + } + + setPendingAttachments((prev) => [...prev, attachment]) + } + reader.readAsDataURL(file) + }) + }, []) + + const handleRemoveAttachment = useCallback((id: string) => { + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + handleFileSelect(e.dataTransfer.files) + }, + [handleFileSelect] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + // Connection status indicator + const ConnectionIndicator = () => { + switch (connectionStatus) { + case 'connected': + return ( + + + Connected + + ) + case 'connecting': + return ( + + + Connecting... + + ) + case 'error': + return ( + + + Error + + ) + default: + return ( + + + Disconnected + + ) + } + } + + return ( +
+ {/* Header */} +
+
+

+ Expand Project: {projectName} +

+ + {featuresCreated > 0 && ( + + + {featuresCreated} added + + )} +
+ +
+ {isComplete && ( + + + Complete + + )} + + +
+
+ + {/* Error banner */} + {error && ( +
+ + {error} + +
+ )} + + {/* Messages area */} +
+ {messages.length === 0 && !isLoading && ( +
+
+

+ Starting Project Expansion +

+

+ Connecting to Claude to help you add new features to your project... +

+ {connectionStatus === 'error' && ( + + )} +
+
+ )} + + {messages.map((message) => ( + + ))} + + {/* Typing indicator */} + {isLoading && } + + {/* Scroll anchor */} +
+
+ + {/* Input area */} + {!isComplete && ( +
+ {/* Attachment previews */} + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ {attachment.filename} + + + {attachment.filename.length > 10 + ? `${attachment.filename.substring(0, 7)}...` + : attachment.filename} + +
+ ))} +
+ )} + +
+ {/* Hidden file input */} + handleFileSelect(e.target.files)} + className="hidden" + /> + + {/* Attach button */} + + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + pendingAttachments.length > 0 + ? 'Add a message with your image(s)...' + : 'Describe the features you want to add...' + } + className="neo-input flex-1" + disabled={isLoading || connectionStatus !== 'connected'} + /> + +
+ + {/* Help text */} +

+ Press Enter to send. Drag & drop or click to attach images. +

+
+ )} + + {/* Completion footer */} + {isComplete && ( +
+
+
+ + + Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}! + +
+ +
+
+ )} +
+ ) +} diff --git a/ui/src/components/ExpandProjectModal.tsx b/ui/src/components/ExpandProjectModal.tsx new file mode 100644 index 00000000..af0d1963 --- /dev/null +++ b/ui/src/components/ExpandProjectModal.tsx @@ -0,0 +1,41 @@ +/** + * Expand Project Modal + * + * Full-screen modal wrapper for the ExpandProjectChat component. + * Allows users to add multiple features to an existing project via AI. + */ + +import { ExpandProjectChat } from './ExpandProjectChat' + +interface ExpandProjectModalProps { + isOpen: boolean + projectName: string + onClose: () => void + onFeaturesAdded: () => void // Called to refresh feature list +} + +export function ExpandProjectModal({ + isOpen, + projectName, + onClose, + onFeaturesAdded, +}: ExpandProjectModalProps) { + if (!isOpen) return null + + const handleComplete = (featuresAdded: number) => { + if (featuresAdded > 0) { + onFeaturesAdded() + } + onClose() + } + + return ( +
+ +
+ ) +} diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts new file mode 100644 index 00000000..0bc48d57 --- /dev/null +++ b/ui/src/hooks/useExpandChat.ts @@ -0,0 +1,323 @@ +/** + * Hook for managing project expansion chat WebSocket connection + */ + +import { useState, useCallback, useRef, useEffect } from 'react' +import type { ChatMessage, ImageAttachment, ExpandChatServerMessage } from '../lib/types' + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +interface CreatedFeature { + id: number + name: string + category: string +} + +interface UseExpandChatOptions { + projectName: string + onComplete?: (totalAdded: number) => void + onError?: (error: string) => void +} + +interface UseExpandChatReturn { + messages: ChatMessage[] + isLoading: boolean + isComplete: boolean + connectionStatus: ConnectionStatus + featuresCreated: number + recentFeatures: CreatedFeature[] + start: () => void + sendMessage: (content: string, attachments?: ImageAttachment[]) => void + disconnect: () => void +} + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +export function useExpandChat({ + projectName, + onComplete, + onError, +}: UseExpandChatOptions): UseExpandChatReturn { + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [featuresCreated, setFeaturesCreated] = useState(0) + const [recentFeatures, setRecentFeatures] = useState([]) + + const wsRef = useRef(null) + const currentAssistantMessageRef = useRef(null) + const reconnectAttempts = useRef(0) + const maxReconnectAttempts = 3 + const pingIntervalRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const isCompleteRef = useRef(false) + + // Keep isCompleteRef in sync with isComplete state + useEffect(() => { + isCompleteRef.current = isComplete + }, [isComplete]) + + // Clean up on unmount + useEffect(() => { + return () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + } + }, []) + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return + } + + setConnectionStatus('connecting') + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsUrl = `${protocol}//${host}/api/expand/ws/${encodeURIComponent(projectName)}` + + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnectionStatus('connected') + reconnectAttempts.current = 0 + + // Start ping interval to keep connection alive + pingIntervalRef.current = window.setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, 30000) + } + + ws.onclose = () => { + setConnectionStatus('disconnected') + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + + // Attempt reconnection if not intentionally closed + if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) { + reconnectAttempts.current++ + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) + reconnectTimeoutRef.current = window.setTimeout(connect, delay) + } + } + + ws.onerror = () => { + setConnectionStatus('error') + onError?.('WebSocket connection error') + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as ExpandChatServerMessage + + switch (data.type) { + case 'text': { + // Append text to current assistant message or create new one + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + // Append to existing streaming message + return [ + ...prev.slice(0, -1), + { + ...lastMessage, + content: lastMessage.content + data.content, + }, + ] + } else { + // Create new assistant message + currentAssistantMessageRef.current = generateId() + return [ + ...prev, + { + id: currentAssistantMessageRef.current, + role: 'assistant', + content: data.content, + timestamp: new Date(), + isStreaming: true, + }, + ] + } + }) + break + } + + case 'features_created': { + // Features were created + setFeaturesCreated((prev) => prev + data.count) + setRecentFeatures(data.features) + + // Add system message about feature creation + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Created ${data.count} new feature${data.count !== 1 ? 's' : ''}!`, + timestamp: new Date(), + }, + ]) + break + } + + case 'expansion_complete': { + setIsComplete(true) + setIsLoading(false) + + // Mark current message as done + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...lastMessage, isStreaming: false }, + ] + } + return prev + }) + + onComplete?.(data.total_added) + break + } + + case 'error': { + setIsLoading(false) + onError?.(data.content) + + // Add error as system message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Error: ${data.content}`, + timestamp: new Date(), + }, + ]) + break + } + + case 'pong': { + // Keep-alive response, nothing to do + break + } + + case 'response_done': { + // Response complete - hide loading indicator and mark message as done + setIsLoading(false) + + // Mark current message as done streaming + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...lastMessage, isStreaming: false }, + ] + } + return prev + }) + break + } + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e) + } + } + }, [projectName, onComplete, onError]) + + const start = useCallback(() => { + connect() + + // Wait for connection then send start message + const checkAndSend = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setIsLoading(true) + wsRef.current.send(JSON.stringify({ type: 'start' })) + } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { + setTimeout(checkAndSend, 100) + } + } + + setTimeout(checkAndSend, 100) + }, [connect]) + + const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.('Not connected') + return + } + + // Add user message to chat (with attachments for display) + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'user', + content, + attachments, + timestamp: new Date(), + }, + ]) + + setIsLoading(true) + + // Build message payload + const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = { + type: 'message', + content, + } + + // Add attachments if present (send base64 data, not preview URL) + if (attachments && attachments.length > 0) { + payload.attachments = attachments.map((a) => ({ + filename: a.filename, + mimeType: a.mimeType, + base64Data: a.base64Data, + })) + } + + // Send to server + wsRef.current.send(JSON.stringify(payload)) + }, [onError]) + + const disconnect = useCallback(() => { + reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + setConnectionStatus('disconnected') + }, []) + + return { + messages, + isLoading, + isComplete, + connectionStatus, + featuresCreated, + recentFeatures, + start, + sendMessage, + disconnect, + } +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index bfee6cc9..83cf1e51 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -9,6 +9,8 @@ import type { FeatureListResponse, Feature, FeatureCreate, + FeatureBulkCreate, + FeatureBulkCreateResponse, AgentStatusResponse, AgentActionResponse, SetupStatus, @@ -111,6 +113,16 @@ export async function skipFeature(projectName: string, featureId: number): Promi }) } +export async function createFeaturesBulk( + projectName: string, + bulk: FeatureBulkCreate +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/bulk`, { + method: 'POST', + body: JSON.stringify(bulk), + }) +} + // ============================================================================ // Agent API // ============================================================================ diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c5de1958..29931a00 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -295,3 +295,37 @@ export type AssistantChatServerMessage = | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage | AssistantChatPongMessage + +// ============================================================================ +// Expand Chat Types +// ============================================================================ + +export interface ExpandChatFeaturesCreatedMessage { + type: 'features_created' + count: number + features: { id: number; name: string; category: string }[] +} + +export interface ExpandChatCompleteMessage { + type: 'expansion_complete' + total_added: number +} + +export type ExpandChatServerMessage = + | SpecChatTextMessage // Reuse text message type + | ExpandChatFeaturesCreatedMessage + | ExpandChatCompleteMessage + | SpecChatErrorMessage // Reuse error message type + | SpecChatPongMessage // Reuse pong message type + | SpecChatResponseDoneMessage // Reuse response_done type + +// Bulk feature creation +export interface FeatureBulkCreate { + features: FeatureCreate[] + starting_priority?: number +} + +export interface FeatureBulkCreateResponse { + created: number + features: Feature[] +} From 75f2bf2a1001cc1e8fa3ce826739f4a581e68c37 Mon Sep 17 00:00:00 2001 From: Dan Gentry Date: Fri, 9 Jan 2026 17:16:06 -0500 Subject: [PATCH 010/265] fix: address code review feedback from coderabbitai - Add language specifier to fenced code block in expand-project.md - Remove detailed exception strings from WebSocket responses (security) - Make WebSocket "start" message idempotent to avoid session reset - Fix race condition in bulk feature creation with row-level lock - Add validation for starting_priority (must be >= 1) - Fix _query_claude to handle multiple feature blocks and deduplicate - Add FileReader error handling in ExpandProjectChat - Fix disconnect() to clear pending reconnect timeout - Enable sandbox mode and validate CLI path in expand_chat_session - Clean up temporary settings file on session close Co-Authored-By: Claude Opus 4.5 --- .claude/commands/expand-project.md | 2 +- server/routers/expand_project.py | 24 ++++++--- server/routers/features.py | 30 ++++++----- server/services/expand_chat_session.py | 72 ++++++++++++++++--------- ui/src/components/ExpandProjectChat.tsx | 3 ++ ui/src/hooks/useExpandChat.ts | 4 ++ 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/.claude/commands/expand-project.md b/.claude/commands/expand-project.md index 06c3df83..bd027318 100644 --- a/.claude/commands/expand-project.md +++ b/.claude/commands/expand-project.md @@ -144,7 +144,7 @@ Once the user approves, create features directly. **Then output the features in this exact JSON format (the system will parse this):** -``` +```json [ { diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index a3256494..0d806d83 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -161,12 +161,22 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): continue elif msg_type == "start": - # Create and start a new expansion session - session = await create_expand_session(project_name, project_dir) + # Check if session already exists (idempotent start) + existing_session = get_expand_session(project_name) + if existing_session: + session = existing_session + await websocket.send_json({ + "type": "text", + "content": "Resuming existing expansion session. What would you like to add?" + }) + await websocket.send_json({"type": "response_done"}) + else: + # Create and start a new expansion session + session = await create_expand_session(project_name, project_dir) - # Stream the initial greeting - async for chunk in session.start(): - await websocket.send_json(chunk) + # Stream the initial greeting + async for chunk in session.start(): + await websocket.send_json(chunk) elif msg_type == "message": # User sent a message @@ -192,7 +202,7 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): logger.warning(f"Invalid attachment data: {e}") await websocket.send_json({ "type": "error", - "content": f"Invalid attachment: {str(e)}" + "content": "Invalid attachment format" }) continue @@ -236,7 +246,7 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): try: await websocket.send_json({ "type": "error", - "content": f"Server error: {str(e)}" + "content": "Internal server error" }) except Exception: pass diff --git a/server/routers/features.py b/server/routers/features.py index 407a92f0..0a5849c5 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -305,7 +305,7 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): Create multiple features at once. Features are assigned sequential priorities starting from: - - starting_priority if specified + - starting_priority if specified (must be >= 1) - max(existing priorities) + 1 if not specified This is useful for: @@ -328,18 +328,28 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): if not bulk.features: return FeatureBulkCreateResponse(created=0, features=[]) + # Validate starting_priority if provided + if bulk.starting_priority is not None and bulk.starting_priority < 1: + raise HTTPException(status_code=400, detail="starting_priority must be >= 1") + _, Feature = _get_db_classes() try: with get_db_session(project_dir) as session: - # Determine starting priority + # Determine starting priority with row-level lock to prevent race conditions if bulk.starting_priority is not None: current_priority = bulk.starting_priority else: - max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first() + # Lock the max priority row to prevent concurrent inserts from getting same priority + max_priority_feature = ( + session.query(Feature) + .order_by(Feature.priority.desc()) + .with_for_update() + .first() + ) current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 - created_features = [] + created_ids = [] for feature_data in bulk.features: db_feature = Feature( @@ -351,20 +361,16 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): passes=False, ) session.add(db_feature) + session.flush() # Flush to get the ID immediately + created_ids.append(db_feature.id) current_priority += 1 session.commit() - # Refresh to get IDs and return responses - for db_feature in session.query(Feature).order_by(Feature.priority.desc()).limit(len(bulk.features)).all(): - created_features.insert(0, feature_to_response(db_feature)) - - # Re-query to get the actual created features in order + # Query created features by their IDs (avoids relying on priority range) created_features = [] - start_priority = current_priority - len(bulk.features) for db_feature in session.query(Feature).filter( - Feature.priority >= start_priority, - Feature.priority < current_priority + Feature.id.in_(created_ids) ).order_by(Feature.priority).all(): created_features.append(feature_to_response(db_feature)) diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 2c458274..fdd90e91 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -67,6 +67,7 @@ def __init__(self, project_name: str, project_dir: Path): self._client_entered: bool = False self.features_created: int = 0 self.created_feature_ids: list[int] = [] + self._settings_file: Optional[Path] = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -79,6 +80,13 @@ async def close(self) -> None: self._client_entered = False self.client = None + # Clean up temporary settings file + if self._settings_file and self._settings_file.exists(): + try: + self._settings_file.unlink() + except Exception as e: + logger.warning(f"Error removing settings file: {e}") + async def start(self) -> AsyncGenerator[dict, None]: """ Initialize session and get initial greeting from Claude. @@ -111,7 +119,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # Create security settings file security_settings = { - "sandbox": {"enabled": False}, + "sandbox": {"enabled": True}, "permissions": { "defaultMode": "acceptEdits", "allow": [ @@ -121,6 +129,7 @@ async def start(self) -> AsyncGenerator[dict, None]: }, } settings_file = self.project_dir / ".claude_settings.json" + self._settings_file = settings_file with open(settings_file, "w") as f: json.dump(security_settings, f, indent=2) @@ -128,8 +137,14 @@ async def start(self) -> AsyncGenerator[dict, None]: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) - # Create Claude SDK client + # Find and validate Claude CLI system_cli = shutil.which("claude") + if not system_cli: + yield { + "type": "error", + "content": "Claude CLI not found. Please install Claude Code." + } + return try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -268,20 +283,35 @@ async def _query_claude( "timestamp": datetime.now().isoformat() }) - # Check for feature creation block in full response - features_match = re.search( + # Check for feature creation blocks in full response (handle multiple blocks) + features_matches = re.findall( r'\s*(\[[\s\S]*?\])\s*', full_response ) - if features_match: - try: - features_json = features_match.group(1) - features_data = json.loads(features_json) - - if features_data and isinstance(features_data, list): - # Create features via REST API - created = await self._create_features_bulk(features_data) + if features_matches: + # Collect all features from all blocks, deduplicating by name + all_features: list[dict] = [] + seen_names: set[str] = set() + + for features_json in features_matches: + try: + features_data = json.loads(features_json) + + if features_data and isinstance(features_data, list): + for feature in features_data: + name = feature.get("name", "") + if name and name not in seen_names: + seen_names.add(name) + all_features.append(feature) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse features JSON block: {e}") + # Continue processing other blocks + + if all_features: + try: + # Create all deduplicated features + created = await self._create_features_bulk(all_features) if created: self.features_created += len(created) @@ -294,18 +324,12 @@ async def _query_claude( } logger.info(f"Created {len(created)} features for {self.project_name}") - except json.JSONDecodeError as e: - logger.error(f"Failed to parse features JSON: {e}") - yield { - "type": "error", - "content": f"Failed to parse feature definitions: {str(e)}" - } - except Exception as e: - logger.exception("Failed to create features") - yield { - "type": "error", - "content": f"Failed to create features: {str(e)}" - } + except Exception as e: + logger.exception("Failed to create features") + yield { + "type": "error", + "content": "Failed to create features" + } async def _create_features_bulk(self, features: list[dict]) -> list[dict]: """ diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx index 2d102d6e..14849335 100644 --- a/ui/src/components/ExpandProjectChat.tsx +++ b/ui/src/components/ExpandProjectChat.tsx @@ -121,6 +121,9 @@ export function ExpandProjectChat({ setPendingAttachments((prev) => [...prev, attachment]) } + reader.onerror = () => { + setError(`Failed to read file: ${file.name}`) + } reader.readAsDataURL(file) }) }, []) diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index 0bc48d57..6a7e73ea 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -302,6 +302,10 @@ export function useExpandChat({ clearInterval(pingIntervalRef.current) pingIntervalRef.current = null } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } if (wsRef.current) { wsRef.current.close() wsRef.current = null From 2b2e28a2c5e7670181b3d13259827352b0162ae1 Mon Sep 17 00:00:00 2001 From: Corey Cauble Date: Fri, 9 Jan 2026 14:30:34 -0800 Subject: [PATCH 011/265] Enhance limit reached message for better context and display in the UI --- agent.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/agent.py b/agent.py index d86bc379..19f15608 100644 --- a/agent.py +++ b/agent.py @@ -200,8 +200,9 @@ async def run_autonomous_agent( if status == "continue": delay_seconds = AUTO_CONTINUE_DELAY_SECONDS target_time_str = None + if response.lower().strip().startswith("limit reached"): - print("Agent indicated limit reached.") + print("Claude Agent SDK indicated limit reached.") # Try to parse reset time from response match = re.search( @@ -241,7 +242,7 @@ async def run_autonomous_agent( if target_time_str: print( - f"\nAgent will auto-continue in {delay_seconds:.0f}s ({target_time_str})...", + f"\nClaude Code Limit Reached. Agent will auto-continue in {delay_seconds:.0f}s ({target_time_str})...", flush=True, ) else: @@ -249,8 +250,8 @@ async def run_autonomous_agent( f"\nAgent will auto-continue in {delay_seconds:.0f}s...", flush=True ) + sys.stdout.flush() # this should allow the pause to be displayed before sleeping print_progress_summary(project_dir) - sys.stdout.flush() await asyncio.sleep(delay_seconds) elif status == "error": From 9c07dd72db9698aed236f11e8da041cebda794e8 Mon Sep 17 00:00:00 2001 From: Corey Cauble Date: Fri, 9 Jan 2026 14:35:20 -0800 Subject: [PATCH 012/265] Fixed issues requested by coderabbitai Applied Fixes More flexible string matching: Changed from response.lower().strip().startswith("limit reached") to "limit reached" in response.lower() to handle cases where the message has prefix text or variations in whitespace. Improved regex pattern: Updated to r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)" which now handles: Optional "at" after "resets" (e.g., "resets at 3pm" or "resets 3pm") Flexible whitespace around components Word boundaries to prevent partial matches Timezone sanitization: Added .strip() to tz_name = match.group(4).strip() to remove any leading/trailing whitespace that could cause ZoneInfo() to fail. Safety clamp: Added delay_seconds = min(delta.total_seconds(), 24 * 60 * 60) to ensure the delay never exceeds 24 hours, preventing the agent from being stuck waiting for extremely long periods. --- agent.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agent.py b/agent.py index 19f15608..50edc46d 100644 --- a/agent.py +++ b/agent.py @@ -201,20 +201,19 @@ async def run_autonomous_agent( delay_seconds = AUTO_CONTINUE_DELAY_SECONDS target_time_str = None - if response.lower().strip().startswith("limit reached"): + if "limit reached" in response.lower(): print("Claude Agent SDK indicated limit reached.") # Try to parse reset time from response match = re.search( - r"resets (\d+)(?::(\d+))?(am|pm) \(([^)]+)\)", + r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)", response, - re.IGNORECASE, ) if match: hour = int(match.group(1)) minute = int(match.group(2)) if match.group(2) else 0 period = match.group(3).lower() - tz_name = match.group(4) + tz_name = match.group(4).strip() # Convert to 24-hour format if period == "pm" and hour != 12: @@ -234,7 +233,9 @@ async def run_autonomous_agent( target += timedelta(days=1) delta = target - now - delay_seconds = delta.total_seconds() + delay_seconds = min( + delta.total_seconds(), 24 * 60 * 60 + ) # Clamp to 24 hours max target_time_str = target.strftime("%B %d, %Y at %I:%M %p %Z") except Exception as e: From cdcbd112726e25f5d7c20950ee4caea9868265fd Mon Sep 17 00:00:00 2001 From: Dan Gentry Date: Fri, 9 Jan 2026 23:57:50 -0500 Subject: [PATCH 013/265] fix: address second round of code review feedback Backend improvements: - Create shared validation utility for project name validation - Add asyncio.Lock to prevent concurrent _query_claude calls - Fix _create_features_bulk: use flush() for IDs, add rollback on error - Use unique temp settings file instead of overwriting .claude_settings.json - Remove exception details from error messages (security) Frontend improvements: - Memoize onError callback in ExpandProjectChat for stable dependencies - Add timeout to start() checkAndSend loop to prevent infinite retries - Add manuallyDisconnectedRef to prevent reconnection after explicit disconnect - Clear pending reconnect timeout in disconnect() Co-Authored-By: Claude Opus 4.5 --- server/routers/expand_project.py | 15 +++--- server/routers/features.py | 12 +---- server/services/expand_chat_session.py | 72 +++++++++++++++---------- server/utils/__init__.py | 1 + server/utils/validation.py | 28 ++++++++++ ui/src/components/ExpandProjectChat.tsx | 5 +- ui/src/hooks/useExpandChat.ts | 26 +++++++-- 7 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 server/utils/__init__.py create mode 100644 server/utils/validation.py diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index 0d806d83..d894719c 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -8,7 +8,6 @@ import json import logging -import re from pathlib import Path from typing import Optional @@ -23,6 +22,7 @@ list_expand_sessions, remove_expand_session, ) +from ..utils.validation import validate_project_name logger = logging.getLogger(__name__) @@ -43,9 +43,6 @@ def _get_project_path(project_name: str) -> Path: return get_project_path(project_name) -def validate_project_name(name: str) -> bool: - """Validate project name to prevent path traversal.""" - return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name)) # ============================================================================ @@ -70,8 +67,7 @@ async def list_expand_sessions_endpoint(): @router.get("/sessions/{project_name}", response_model=ExpandSessionStatus) async def get_expand_session_status(project_name: str): """Get status of an expansion session.""" - if not validate_project_name(project_name): - raise HTTPException(status_code=400, detail="Invalid project name") + project_name = validate_project_name(project_name) session = get_expand_session(project_name) if not session: @@ -89,8 +85,7 @@ async def get_expand_session_status(project_name: str): @router.delete("/sessions/{project_name}") async def cancel_expand_session(project_name: str): """Cancel and remove an expansion session.""" - if not validate_project_name(project_name): - raise HTTPException(status_code=400, detail="Invalid project name") + project_name = validate_project_name(project_name) session = get_expand_session(project_name) if not session: @@ -124,7 +119,9 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): - {"type": "error", "content": "..."} - Error message - {"type": "pong"} - Keep-alive pong """ - if not validate_project_name(project_name): + try: + project_name = validate_project_name(project_name) + except HTTPException: await websocket.close(code=4000, reason="Invalid project name") return diff --git a/server/routers/features.py b/server/routers/features.py index 0a5849c5..ce0f388d 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -6,7 +6,6 @@ """ import logging -import re from contextlib import contextmanager from pathlib import Path @@ -19,6 +18,7 @@ FeatureListResponse, FeatureResponse, ) +from ..utils.validation import validate_project_name # Lazy imports to avoid circular dependencies _create_database = None @@ -56,16 +56,6 @@ def _get_db_classes(): router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"]) -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 - - @contextmanager def get_db_session(project_dir: Path): """ diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index fdd90e91..a6825f63 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -6,11 +6,13 @@ Uses the expand-project.md skill to help users add features to existing projects. """ +import asyncio import json import logging import re import shutil import threading +import uuid from datetime import datetime from pathlib import Path from typing import AsyncGenerator, Optional @@ -68,6 +70,7 @@ def __init__(self, project_name: str, project_dir: Path): self.features_created: int = 0 self.created_feature_ids: list[int] = [] self._settings_file: Optional[Path] = None + self._query_lock = asyncio.Lock() async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -117,7 +120,16 @@ async def start(self) -> AsyncGenerator[dict, None]: except UnicodeDecodeError: skill_content = skill_path.read_text(encoding="utf-8", errors="replace") - # Create security settings file + # Find and validate Claude CLI before creating temp files + system_cli = shutil.which("claude") + if not system_cli: + yield { + "type": "error", + "content": "Claude CLI not found. Please install Claude Code." + } + return + + # Create temporary security settings file (unique per session to avoid conflicts) security_settings = { "sandbox": {"enabled": True}, "permissions": { @@ -128,23 +140,16 @@ async def start(self) -> AsyncGenerator[dict, None]: ], }, } - settings_file = self.project_dir / ".claude_settings.json" + settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json" self._settings_file = settings_file - with open(settings_file, "w") as f: + with open(settings_file, "w", encoding="utf-8") as f: json.dump(security_settings, f, indent=2) # Replace $ARGUMENTS with absolute project path project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) - # Find and validate Claude CLI - system_cli = shutil.which("claude") - if not system_cli: - yield { - "type": "error", - "content": "Claude CLI not found. Please install Claude Code." - } - return + # Create Claude SDK client try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -167,20 +172,21 @@ async def start(self) -> AsyncGenerator[dict, None]: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": f"Failed to initialize Claude: {str(e)}" + "content": "Failed to initialize Claude" } return # Start the conversation try: - async for chunk in self._query_claude("Begin the project expansion process."): - yield chunk + async with self._query_lock: + async for chunk in self._query_claude("Begin the project expansion process."): + yield chunk yield {"type": "response_done"} except Exception as e: logger.exception("Failed to start expand chat") yield { "type": "error", - "content": f"Failed to start conversation: {str(e)}" + "content": "Failed to start conversation" } async def send_message( @@ -218,14 +224,16 @@ async def send_message( }) try: - async for chunk in self._query_claude(user_message, attachments): - yield chunk + # Use lock to prevent concurrent queries from corrupting the response stream + async with self._query_lock: + async for chunk in self._query_claude(user_message, attachments): + yield chunk yield {"type": "response_done"} except Exception as e: logger.exception("Error during Claude query") yield { "type": "error", - "content": f"Error: {str(e)}" + "content": "Error while processing message" } async def _query_claude( @@ -340,6 +348,10 @@ async def _create_features_bulk(self, features: list[dict]) -> list[dict]: Returns: List of created feature dictionaries with IDs + + Note: + Uses flush() to get IDs immediately without re-querying by priority range, + which could pick up rows from concurrent writers. """ # Import database classes import sys @@ -358,7 +370,7 @@ async def _create_features_bulk(self, features: list[dict]) -> list[dict]: max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first() current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 - created_features = [] + created_rows: list = [] for f in features: db_feature = Feature( @@ -370,24 +382,28 @@ async def _create_features_bulk(self, features: list[dict]) -> list[dict]: passes=False, ) session.add(db_feature) + created_rows.append(db_feature) current_priority += 1 - session.commit() + # Flush to get IDs without relying on priority range query + session.flush() - # Re-query to get the created features with IDs - start_priority = current_priority - len(features) - for db_feature in session.query(Feature).filter( - Feature.priority >= start_priority, - Feature.priority < current_priority - ).order_by(Feature.priority).all(): - created_features.append({ + # Build result from the flushed objects (IDs are now populated) + created_features = [ + { "id": db_feature.id, "name": db_feature.name, "category": db_feature.category, - }) + } + for db_feature in created_rows + ] + session.commit() return created_features + except Exception: + session.rollback() + raise finally: session.close() diff --git a/server/utils/__init__.py b/server/utils/__init__.py new file mode 100644 index 00000000..8ed4d66c --- /dev/null +++ b/server/utils/__init__.py @@ -0,0 +1 @@ +# Server utilities diff --git a/server/utils/validation.py b/server/utils/validation.py new file mode 100644 index 00000000..9f1bf118 --- /dev/null +++ b/server/utils/validation.py @@ -0,0 +1,28 @@ +""" +Shared validation utilities for the server. +""" + +import re + +from fastapi import HTTPException + + +def validate_project_name(name: str) -> str: + """ + Validate and sanitize project name to prevent path traversal. + + Args: + name: Project name to validate + + Returns: + The validated project name + + Raises: + HTTPException: If name is invalid + """ + if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name): + raise HTTPException( + status_code=400, + detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)." + ) + return name diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx index 14849335..1077a6da 100644 --- a/ui/src/components/ExpandProjectChat.tsx +++ b/ui/src/components/ExpandProjectChat.tsx @@ -34,6 +34,9 @@ export function ExpandProjectChat({ const inputRef = useRef(null) const fileInputRef = useRef(null) + // Memoize error handler to keep hook dependencies stable + const handleError = useCallback((err: string) => setError(err), []) + const { messages, isLoading, @@ -46,7 +49,7 @@ export function ExpandProjectChat({ } = useExpandChat({ projectName, onComplete, - onError: (err) => setError(err), + onError: handleError, }) // Start the chat session when component mounts diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index 6a7e73ea..91508852 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -54,6 +54,7 @@ export function useExpandChat({ const pingIntervalRef = useRef(null) const reconnectTimeoutRef = useRef(null) const isCompleteRef = useRef(false) + const manuallyDisconnectedRef = useRef(false) // Keep isCompleteRef in sync with isComplete state useEffect(() => { @@ -76,6 +77,10 @@ export function useExpandChat({ }, []) const connect = useCallback(() => { + // Don't reconnect if manually disconnected + if (manuallyDisconnectedRef.current) { + return + } if (wsRef.current?.readyState === WebSocket.OPEN) { return } @@ -92,6 +97,7 @@ export function useExpandChat({ ws.onopen = () => { setConnectionStatus('connected') reconnectAttempts.current = 0 + manuallyDisconnectedRef.current = false // Start ping interval to keep connection alive pingIntervalRef.current = window.setInterval(() => { @@ -109,7 +115,11 @@ export function useExpandChat({ } // Attempt reconnection if not intentionally closed - if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) { + if ( + !manuallyDisconnectedRef.current && + reconnectAttempts.current < maxReconnectAttempts && + !isCompleteRef.current + ) { reconnectAttempts.current++ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) reconnectTimeoutRef.current = window.setTimeout(connect, delay) @@ -244,18 +254,25 @@ export function useExpandChat({ const start = useCallback(() => { connect() - // Wait for connection then send start message + // Wait for connection then send start message (with timeout to prevent infinite loop) + let attempts = 0 + const maxAttempts = 50 // 5 seconds max (50 * 100ms) const checkAndSend = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { setIsLoading(true) wsRef.current.send(JSON.stringify({ type: 'start' })) } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { - setTimeout(checkAndSend, 100) + if (attempts++ < maxAttempts) { + setTimeout(checkAndSend, 100) + } else { + onError?.('Connection timeout') + setIsLoading(false) + } } } setTimeout(checkAndSend, 100) - }, [connect]) + }, [connect, onError]) const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { @@ -297,6 +314,7 @@ export function useExpandChat({ }, [onError]) const disconnect = useCallback(() => { + manuallyDisconnectedRef.current = true reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) From dff28c53bfa05648c338f1d22317c679091e89cc Mon Sep 17 00:00:00 2001 From: Auto Date: Sat, 10 Jan 2026 09:51:54 +0200 Subject: [PATCH 014/265] fix: handle cross-platform venv compatibility in WSL Changes: - start_ui.sh, start.sh: Check for venv/bin/activate instead of just venv/ directory to detect Windows venvs in Linux/WSL - Auto-recreate venv when incompatible platform structure detected - Add error handling for venv removal, creation, and activation failures - Provide actionable error messages with distro-specific instructions - start_ui.bat: Check for venv\Scripts\activate.bat for consistency with start.bat pattern Fixes issue where users cloning repo in WSL would encounter: - "venv/bin/activate: No such file or directory" - "No module named pip" errors Co-Authored-By: Claude Opus 4.5 --- start.sh | 31 ++++++++++++++++++++++++++++--- start_ui.bat | 4 ++-- start_ui.sh | 31 ++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/start.sh b/start.sh index d90c0977..666a11af 100644 --- a/start.sh +++ b/start.sh @@ -58,14 +58,39 @@ fi echo "" -# Check if venv exists, create if not -if [ ! -d "venv" ]; then - echo "Creating virtual environment..." +# Check if venv exists with correct structure for this platform +# Windows venvs have Scripts/, Linux/macOS have bin/ +if [ ! -f "venv/bin/activate" ]; then + if [ -d "venv" ]; then + echo "[INFO] Detected incompatible virtual environment (possibly created on Windows)" + echo "[INFO] Recreating virtual environment for this platform..." + rm -rf venv + if [ -d "venv" ]; then + echo "[ERROR] Failed to remove existing virtual environment" + echo "Please manually delete the 'venv' directory and try again:" + echo " rm -rf venv" + exit 1 + fi + else + echo "Creating virtual environment..." + fi python3 -m venv venv + if [ $? -ne 0 ]; then + echo "[ERROR] Failed to create virtual environment" + echo "Please ensure the venv module is installed:" + echo " Ubuntu/Debian: sudo apt install python3-venv" + echo " Or try: python3 -m ensurepip" + exit 1 + fi fi # Activate the virtual environment source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[ERROR] Failed to activate virtual environment" + echo "The venv may be corrupted. Try: rm -rf venv && ./start.sh" + exit 1 +fi # Install dependencies echo "Installing dependencies..." diff --git a/start_ui.bat b/start_ui.bat index d53ca9c1..8616b1ab 100644 --- a/start_ui.bat +++ b/start_ui.bat @@ -18,8 +18,8 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) -REM Check if venv exists, create if not -if not exist "venv" ( +REM Check if venv exists with correct activation script +if not exist "venv\Scripts\activate.bat" ( echo Creating virtual environment... python -m venv venv ) diff --git a/start_ui.sh b/start_ui.sh index 644db747..db3d6fa1 100644 --- a/start_ui.sh +++ b/start_ui.sh @@ -21,14 +21,39 @@ else PYTHON_CMD="python3" fi -# Check if venv exists, create if not -if [ ! -d "venv" ]; then - echo "Creating virtual environment..." +# Check if venv exists with correct structure for this platform +# Windows venvs have Scripts/, Linux/macOS have bin/ +if [ ! -f "venv/bin/activate" ]; then + if [ -d "venv" ]; then + echo "[INFO] Detected incompatible virtual environment (possibly created on Windows)" + echo "[INFO] Recreating virtual environment for this platform..." + rm -rf venv + if [ -d "venv" ]; then + echo "[ERROR] Failed to remove existing virtual environment" + echo "Please manually delete the 'venv' directory and try again:" + echo " rm -rf venv" + exit 1 + fi + else + echo "Creating virtual environment..." + fi $PYTHON_CMD -m venv venv + if [ $? -ne 0 ]; then + echo "[ERROR] Failed to create virtual environment" + echo "Please ensure the venv module is installed:" + echo " Ubuntu/Debian: sudo apt install python3-venv" + echo " Or try: $PYTHON_CMD -m ensurepip" + exit 1 + fi fi # Activate the virtual environment source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[ERROR] Failed to activate virtual environment" + echo "The venv may be corrupted. Try: rm -rf venv && ./start_ui.sh" + exit 1 +fi # Install dependencies echo "Installing dependencies..." From cbe3ecd25d2942f21c9bada35a5997fbd9437841 Mon Sep 17 00:00:00 2001 From: Auto Date: Sat, 10 Jan 2026 10:07:33 +0200 Subject: [PATCH 015/265] fix: resolve CI linting errors for Python and ESLint Python (ruff F401 - unused imports): - Remove unused DEFAULT_YOLO_MODE import from server/routers/settings.py - Remove unused AVAILABLE_MODELS import from server/schemas.py ESLint (missing config for v9): - Add ui/eslint.config.js with flat config format for ESLint v9 - Configure TypeScript, React Hooks, and React Refresh plugins - Fix unnecessary regex escapes in AgentThought.tsx - Remove unused onComplete from useSpecChat.ts dependency array Additional: - Add .claude/commands/check-code.md for local CI verification Co-Authored-By: Claude Opus 4.5 --- .claude/commands/check-code.md | 32 ++++++++++++++++++++++++++++++ server/routers/settings.py | 1 - server/schemas.py | 2 +- ui/eslint.config.js | 28 ++++++++++++++++++++++++++ ui/src/components/AgentThought.tsx | 2 +- ui/src/hooks/useSpecChat.ts | 4 ++-- 6 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 .claude/commands/check-code.md create mode 100644 ui/eslint.config.js diff --git a/.claude/commands/check-code.md b/.claude/commands/check-code.md new file mode 100644 index 00000000..55492619 --- /dev/null +++ b/.claude/commands/check-code.md @@ -0,0 +1,32 @@ +--- +description: +--- + +Run the following commands and ensure the code is clean. + +From project root: + +# Python linting + +ruff check . + +# Security tests + +python test_security.py + +From ui/ directory: +cd ui + +# ESLint (will fail until we add the config) + +npm run lint + +# TypeScript check + build + +npm run build + +One-liner to run everything: +ruff check . && python test_security.py && cd ui && npm run lint && npm run build + +Or if you want to see all failures at once (doesn't stop on first error): +ruff check .; python test_security.py; cd ui && npm run lint; npm run build diff --git a/server/routers/settings.py b/server/routers/settings.py index 10b0fa32..18362eea 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -21,7 +21,6 @@ from registry import ( AVAILABLE_MODELS, DEFAULT_MODEL, - DEFAULT_YOLO_MODE, get_all_settings, set_setting, ) diff --git a/server/schemas.py b/server/schemas.py index 842906a8..1f67c5d3 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -18,7 +18,7 @@ if str(_root) not in sys.path: sys.path.insert(0, str(_root)) -from registry import AVAILABLE_MODELS, DEFAULT_MODEL, VALID_MODELS +from registry import DEFAULT_MODEL, VALID_MODELS # ============================================================================ # Project Schemas diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 00000000..092408a9 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/ui/src/components/AgentThought.tsx b/ui/src/components/AgentThought.tsx index 8cc85084..65a50a11 100644 --- a/ui/src/components/AgentThought.tsx +++ b/ui/src/components/AgentThought.tsx @@ -24,7 +24,7 @@ function isAgentThought(line: string): boolean { if (/^Output:/.test(trimmed)) return false // Skip JSON and very short lines - if (/^[\[\{]/.test(trimmed)) return false + if (/^[[{]/.test(trimmed)) return false if (trimmed.length < 15) return false // Skip lines that are just paths or technical output diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts index 7d9fd4b9..b2bac628 100644 --- a/ui/src/hooks/useSpecChat.ts +++ b/ui/src/hooks/useSpecChat.ts @@ -33,7 +33,7 @@ function generateId(): string { export function useSpecChat({ projectName, - onComplete, + // onComplete intentionally not used - user clicks "Continue to Project" button instead onError, }: UseSpecChatOptions): UseSpecChatReturn { const [messages, setMessages] = useState([]) @@ -346,7 +346,7 @@ export function useSpecChat({ console.error('Failed to parse WebSocket message:', e) } } - }, [projectName, onComplete, onError]) + }, [projectName, onError]) const start = useCallback(() => { connect() From 1998de7c50f4be5398d8f5fbc8a81a1482ef03c3 Mon Sep 17 00:00:00 2001 From: Auto Date: Sat, 10 Jan 2026 10:50:28 +0200 Subject: [PATCH 016/265] fix: resolve merge conflicts and clean up expand project feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge fixes for PR #36 (expand-project-with-ai): - Fix syntax error in App.tsx Escape handler (missing `} else`) - Fix missing closing brace in types.ts FeatureBulkCreateResponse - Remove unused exception variables flagged by ruff (F841) - Make nav buttons minimalist: remove text labels, keep icons + shortcuts - "Add Feature" → icon + N shortcut, tooltip "Add new feature" - "Expand" → icon + E shortcut, tooltip "Expand project with AI" All checks pass: ruff, security tests, ESLint, TypeScript build. Co-Authored-By: Claude Opus 4.5 --- server/routers/expand_project.py | 2 +- server/services/expand_chat_session.py | 8 ++++---- ui/src/App.tsx | 8 +++----- ui/src/lib/types.ts | 3 +++ ui/tsconfig.tsbuildinfo | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index d894719c..50bf1962 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -238,7 +238,7 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): except WebSocketDisconnect: logger.info(f"Expand chat WebSocket disconnected for {project_name}") - except Exception as e: + except Exception: logger.exception(f"Expand chat WebSocket error for {project_name}") try: await websocket.send_json({ diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index a6825f63..6c6b430d 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -168,7 +168,7 @@ async def start(self) -> AsyncGenerator[dict, None]: ) await self.client.__aenter__() self._client_entered = True - except Exception as e: + except Exception: logger.exception("Failed to create Claude client") yield { "type": "error", @@ -182,7 +182,7 @@ async def start(self) -> AsyncGenerator[dict, None]: async for chunk in self._query_claude("Begin the project expansion process."): yield chunk yield {"type": "response_done"} - except Exception as e: + except Exception: logger.exception("Failed to start expand chat") yield { "type": "error", @@ -229,7 +229,7 @@ async def send_message( async for chunk in self._query_claude(user_message, attachments): yield chunk yield {"type": "response_done"} - except Exception as e: + except Exception: logger.exception("Error during Claude query") yield { "type": "error", @@ -332,7 +332,7 @@ async def _query_claude( } logger.info(f"Created {len(created)} features for {self.project_name}") - except Exception as e: + except Exception: logger.exception("Failed to create features") yield { "type": "error", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 428a1718..7aff9017 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -116,7 +116,7 @@ function App() { if (e.key === 'Escape') { if (showExpandProject) { setShowExpandProject(false) - if (showSettings) { + } else if (showSettings) { setShowSettings(false) } else if (assistantOpen) { setAssistantOpen(false) @@ -174,10 +174,9 @@ function App() { +
+ + {/* Content */} +
+

+ {message} +

+ + {/* Actions */} +
+ + +
+
+
+ + ) +} diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index 03e620b6..8525a633 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -1,7 +1,9 @@ import { useState } from 'react' -import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react' +import { ChevronDown, Plus, FolderOpen, Loader2, Trash2 } from 'lucide-react' import type { ProjectSummary } from '../lib/types' import { NewProjectModal } from './NewProjectModal' +import { ConfirmDialog } from './ConfirmDialog' +import { useDeleteProject } from '../hooks/useProjects' interface ProjectSelectorProps { projects: ProjectSummary[] @@ -18,12 +20,42 @@ export function ProjectSelector({ }: ProjectSelectorProps) { const [isOpen, setIsOpen] = useState(false) const [showNewProjectModal, setShowNewProjectModal] = useState(false) + const [projectToDelete, setProjectToDelete] = useState(null) + + const deleteProject = useDeleteProject() const handleProjectCreated = (projectName: string) => { onSelectProject(projectName) setIsOpen(false) } + const handleDeleteClick = (e: React.MouseEvent, projectName: string) => { + // Prevent the click from selecting the project + e.stopPropagation() + setProjectToDelete(projectName) + } + + const handleConfirmDelete = async () => { + if (!projectToDelete) return + + try { + await deleteProject.mutateAsync(projectToDelete) + // If the deleted project was selected, clear the selection + if (selectedProject === projectToDelete) { + onSelectProject(null) + } + setProjectToDelete(null) + } catch (error) { + // Error is handled by the mutation, just close the dialog + console.error('Failed to delete project:', error) + setProjectToDelete(null) + } + } + + const handleCancelDelete = () => { + setProjectToDelete(null) + } + const selectedProjectData = projects.find(p => p.name === selectedProject) return ( @@ -70,28 +102,39 @@ export function ProjectSelector({ {projects.length > 0 ? (
{projects.map(project => ( - + {project.stats.total > 0 && ( + + {project.stats.passing}/{project.stats.total} + + )} + + +
))} ) : ( @@ -124,6 +167,19 @@ export function ProjectSelector({ onClose={() => setShowNewProjectModal(false)} onProjectCreated={handleProjectCreated} /> + + {/* Delete Confirmation Dialog */} + ) } diff --git a/ui/src/components/SpecCreationChat.tsx b/ui/src/components/SpecCreationChat.tsx index acfb24db..ee14ee2f 100644 --- a/ui/src/components/SpecCreationChat.tsx +++ b/ui/src/components/SpecCreationChat.tsx @@ -43,7 +43,7 @@ export function SpecCreationChat({ const [yoloEnabled, setYoloEnabled] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) const messagesEndRef = useRef(null) - const inputRef = useRef(null) + const inputRef = useRef(null) const fileInputRef = useRef(null) const { @@ -98,6 +98,10 @@ export function SpecCreationChat({ sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) setInput('') setPendingAttachments([]) // Clear attachments after sending + // Reset textarea height after sending + if (inputRef.current) { + inputRef.current.style.height = 'auto' + } } const handleKeyDown = (e: React.KeyboardEvent) => { @@ -355,11 +359,15 @@ export function SpecCreationChat({ - setInput(e.target.value)} + onChange={(e) => { + setInput(e.target.value) + // Auto-resize the textarea + e.target.style.height = 'auto' + e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px` + }} onKeyDown={handleKeyDown} placeholder={ currentQuestions @@ -368,8 +376,9 @@ export function SpecCreationChat({ ? 'Add a message with your image(s)...' : 'Type your response... (or /exit to go to project)' } - className="neo-input flex-1" + className="neo-input flex-1 resize-none min-h-[46px] max-h-[200px] overflow-y-auto" disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'} + rows={1} /> - - {/* Expand Project - only show if project has features */} - {features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && ( - - )} - setShowAddFeature(true)} + onExpandProject={() => setShowExpandProject(true)} /> )} diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index d070c70f..00083676 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -4,9 +4,13 @@ import type { Feature, FeatureListResponse } from '../lib/types' interface KanbanBoardProps { features: FeatureListResponse | undefined onFeatureClick: (feature: Feature) => void + onAddFeature?: () => void + onExpandProject?: () => void } -export function KanbanBoard({ features, onFeatureClick }: KanbanBoardProps) { +export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject }: KanbanBoardProps) { + const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0 + if (!features) { return (
@@ -32,6 +36,9 @@ export function KanbanBoard({ features, onFeatureClick }: KanbanBoardProps) { features={features.pending} color="pending" onFeatureClick={onFeatureClick} + onAddFeature={onAddFeature} + onExpandProject={onExpandProject} + showExpandButton={hasFeatures} /> void + onAddFeature?: () => void + onExpandProject?: () => void + showExpandButton?: boolean } const colorMap = { @@ -21,6 +25,9 @@ export function KanbanColumn({ features, color, onFeatureClick, + onAddFeature, + onExpandProject, + showExpandButton, }: KanbanColumnProps) { return (
-

- {title} - {count} -

+
+

+ {title} + {count} +

+ {(onAddFeature || onExpandProject) && ( +
+ {onAddFeature && ( + + )} + {onExpandProject && showExpandButton && ( + + )} +
+ )} +
{/* Cards */} From b18ca801746f8f654c63f94c18c834efa547555b Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 11 Jan 2026 11:01:02 +0200 Subject: [PATCH 022/265] fix: hide AssistantFAB during spec creation mode The Chat AI Assistant button (AssistantFAB) was appearing on top of the full-screen spec creation chat overlay, causing a visual bug where the button would overlap with the Send input area. Changes: - Add onStepChange callback prop to NewProjectModal to notify parent when the modal step changes - Add onSpecCreatingChange callback prop to ProjectSelector to propagate spec creation state up to App.tsx - Add isSpecCreating state to App.tsx to track when spec creation chat is active - Update AssistantFAB render condition to include !isSpecCreating - Disable 'A' keyboard shortcut during spec creation mode The fix propagates the spec creation state through the component hierarchy: NewProjectModal -> ProjectSelector -> App.tsx, allowing the FAB to be hidden when step === 'chat' in the new project modal. Co-Authored-By: Claude Opus 4.5 --- ui/src/App.tsx | 12 ++++++----- ui/src/components/NewProjectModal.tsx | 30 +++++++++++++++++---------- ui/src/components/ProjectSelector.tsx | 3 +++ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fec93050..328a31b7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -39,6 +39,7 @@ function App() { const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height const [assistantOpen, setAssistantOpen] = useState(false) const [showSettings, setShowSettings] = useState(false) + const [isSpecCreating, setIsSpecCreating] = useState(false) const queryClient = useQueryClient() const { data: projects, isLoading: projectsLoading } = useProjects() @@ -100,8 +101,8 @@ function App() { setShowExpandProject(true) } - // A : Toggle assistant panel (when project selected) - if ((e.key === 'a' || e.key === 'A') && selectedProject) { + // A : Toggle assistant panel (when project selected and not in spec creation) + if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) { e.preventDefault() setAssistantOpen(prev => !prev) } @@ -132,7 +133,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings]) + }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings, isSpecCreating]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -167,6 +168,7 @@ function App() { selectedProject={selectedProject} onSelectProject={handleSelectProject} isLoading={projectsLoading} + onSpecCreatingChange={setIsSpecCreating} /> {selectedProject && ( @@ -290,8 +292,8 @@ function App() { /> )} - {/* Assistant FAB and Panel - hide FAB when expand modal is open */} - {selectedProject && !showExpandProject && ( + {/* Assistant FAB and Panel - hide when expand modal or spec creation is open */} + {selectedProject && !showExpandProject && !isSpecCreating && ( <> setAssistantOpen(!assistantOpen)} diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx index 2590d7ea..b517fa53 100644 --- a/ui/src/components/NewProjectModal.tsx +++ b/ui/src/components/NewProjectModal.tsx @@ -25,12 +25,14 @@ interface NewProjectModalProps { isOpen: boolean onClose: () => void onProjectCreated: (projectName: string) => void + onStepChange?: (step: Step) => void } export function NewProjectModal({ isOpen, onClose, onProjectCreated, + onStepChange, }: NewProjectModalProps) { const [step, setStep] = useState('name') const [projectName, setProjectName] = useState('') @@ -46,6 +48,12 @@ export function NewProjectModal({ const createProject = useCreateProject() + // Wrapper to notify parent of step changes + const changeStep = (newStep: Step) => { + setStep(newStep) + onStepChange?.(newStep) + } + if (!isOpen) return null const handleNameSubmit = (e: React.FormEvent) => { @@ -63,18 +71,18 @@ export function NewProjectModal({ } setError(null) - setStep('folder') + changeStep('folder') } const handleFolderSelect = (path: string) => { // Append project name to the selected path const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}` setProjectPath(fullPath) - setStep('method') + changeStep('method') } const handleFolderCancel = () => { - setStep('name') + changeStep('name') } const handleMethodSelect = async (method: SpecMethod) => { @@ -82,7 +90,7 @@ export function NewProjectModal({ if (!projectPath) { setError('Please select a project folder first') - setStep('folder') + changeStep('folder') return } @@ -94,7 +102,7 @@ export function NewProjectModal({ path: projectPath, specMethod: 'manual', }) - setStep('complete') + changeStep('complete') setTimeout(() => { onProjectCreated(project.name) handleClose() @@ -110,7 +118,7 @@ export function NewProjectModal({ path: projectPath, specMethod: 'claude', }) - setStep('chat') + changeStep('chat') } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Failed to create project') } @@ -125,7 +133,7 @@ export function NewProjectModal({ try { await startAgent(projectName.trim(), yoloMode) // Success - navigate to project - setStep('complete') + changeStep('complete') setTimeout(() => { onProjectCreated(projectName.trim()) handleClose() @@ -144,7 +152,7 @@ export function NewProjectModal({ const handleChatCancel = () => { // Go back to method selection but keep the project - setStep('method') + changeStep('method') setSpecMethod(null) } @@ -155,7 +163,7 @@ export function NewProjectModal({ } const handleClose = () => { - setStep('name') + changeStep('name') setProjectName('') setProjectPath(null) setSpecMethod(null) @@ -168,10 +176,10 @@ export function NewProjectModal({ const handleBack = () => { if (step === 'method') { - setStep('folder') + changeStep('folder') setSpecMethod(null) } else if (step === 'folder') { - setStep('name') + changeStep('name') setProjectPath(null) } } diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index 8525a633..3c50769d 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -10,6 +10,7 @@ interface ProjectSelectorProps { selectedProject: string | null onSelectProject: (name: string | null) => void isLoading: boolean + onSpecCreatingChange?: (isCreating: boolean) => void } export function ProjectSelector({ @@ -17,6 +18,7 @@ export function ProjectSelector({ selectedProject, onSelectProject, isLoading, + onSpecCreatingChange, }: ProjectSelectorProps) { const [isOpen, setIsOpen] = useState(false) const [showNewProjectModal, setShowNewProjectModal] = useState(false) @@ -166,6 +168,7 @@ export function ProjectSelector({ isOpen={showNewProjectModal} onClose={() => setShowNewProjectModal(false)} onProjectCreated={handleProjectCreated} + onStepChange={(step) => onSpecCreatingChange?.(step === 'chat')} /> {/* Delete Confirmation Dialog */} From aede8f720e6170830af1f657346936959df72c3d Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 11 Jan 2026 11:30:34 +0200 Subject: [PATCH 023/265] fix: decouple project name from folder path in project creation Remove automatic subfolder creation when creating projects. Users now select the exact folder they want to use, enabling support for existing projects without requiring folder names to match project names. Changes: - NewProjectModal.tsx: Remove path concatenation that appended project name to selected folder. Update instruction text to clarify users select THE project folder, not a parent location. - FolderBrowser.tsx: Add visual indicator "This folder will contain all project files" to clarify selection behavior. - projects.py: Add duplicate path validation to prevent multiple projects from registering the same directory. Includes case-insensitive path comparison on Windows for proper cross-platform support. This allows users to: - Use Auto Coder on existing projects by selecting their folder directly - Have project names that differ from folder names (name is a registry label) - Get clear feedback when a path is already registered under another name Co-Authored-By: Claude Opus 4.5 --- server/routers/projects.py | 19 ++++++++++++++++++- ui/src/components/FolderBrowser.tsx | 5 +++++ ui/src/components/NewProjectModal.tsx | 6 ++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/server/routers/projects.py b/server/routers/projects.py index d1c2b6c7..68cf5268 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -8,6 +8,7 @@ import re import shutil +import sys from pathlib import Path from fastapi import APIRouter, HTTPException @@ -131,7 +132,7 @@ async def list_projects(): async def create_project(project: ProjectCreate): """Create a new project at the specified path.""" _init_imports() - register_project, _, get_project_path, _, _ = _get_registry_functions() + register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions() name = validate_project_name(project.name) project_path = Path(project.path).resolve() @@ -144,6 +145,22 @@ async def create_project(project: ProjectCreate): detail=f"Project '{name}' already exists at {existing}" ) + # Check if path already registered under a different name + all_projects = list_registered_projects() + for existing_name, info in all_projects.items(): + existing_path = Path(info["path"]).resolve() + # Case-insensitive comparison on Windows + if sys.platform == "win32": + paths_match = str(existing_path).lower() == str(project_path).lower() + else: + paths_match = existing_path == project_path + + if paths_match: + raise HTTPException( + status_code=409, + detail=f"Path '{project_path}' is already registered as project '{existing_name}'" + ) + # Security: Check if path is in a blocked location from .filesystem import is_path_blocked if is_path_blocked(project_path): diff --git a/ui/src/components/FolderBrowser.tsx b/ui/src/components/FolderBrowser.tsx index fc97d4f2..1e04e3a6 100644 --- a/ui/src/components/FolderBrowser.tsx +++ b/ui/src/components/FolderBrowser.tsx @@ -304,6 +304,11 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
Selected path:
{selectedPath || 'No folder selected'}
+ {selectedPath && ( +
+ This folder will contain all project files +
+ )}
{/* Actions */} diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx index b517fa53..e3aa755b 100644 --- a/ui/src/components/NewProjectModal.tsx +++ b/ui/src/components/NewProjectModal.tsx @@ -75,9 +75,7 @@ export function NewProjectModal({ } const handleFolderSelect = (path: string) => { - // Append project name to the selected path - const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}` - setProjectPath(fullPath) + setProjectPath(path) // Use selected path directly - no subfolder creation changeStep('method') } @@ -218,7 +216,7 @@ export function NewProjectModal({ Select Project Location

- A folder named {projectName} will be created inside the selected directory + Select the folder to use for project {projectName}. Create a new folder or choose an existing one.

From b1473cdfb97a343bf7178159951bdecf386a7fba Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 11 Jan 2026 11:46:08 +0200 Subject: [PATCH 024/265] fix: reset WebSocket state when switching projects Add state reset at the start of the project change effect in useWebSocket hook. This clears stale progress data, agent status, and logs when the user switches to a different project, preventing display of outdated information from the previous project. Co-Authored-By: Claude Opus 4.5 --- ui/src/hooks/useWebSocket.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index bf9e2b84..0e390daa 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -125,6 +125,14 @@ export function useProjectWebSocket(projectName: string | null) { // Connect when project changes useEffect(() => { + // Reset state when project changes to clear stale data + setState({ + progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, + agentStatus: 'stopped', + logs: [], + isConnected: false, + }) + if (!projectName) { // Disconnect if no project if (wsRef.current) { From c1985eb285a34282a5e9e891193a2eb13d8a70e8 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 10:35:36 +0200 Subject: [PATCH 025/265] 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 --- .gitignore | 1 + requirements.txt | 1 + server/main.py | 14 +- server/routers/__init__.py | 4 + server/routers/devserver.py | 280 ++++++++++++ server/routers/terminal.py | 273 ++++++++++++ server/schemas.py | 58 +++ server/services/__init__.py | 28 +- server/services/dev_server_manager.py | 556 ++++++++++++++++++++++++ server/services/project_config.py | 466 ++++++++++++++++++++ server/services/terminal_manager.py | 563 +++++++++++++++++++++++++ server/websocket.py | 47 ++- ui/package-lock.json | 28 +- ui/package.json | 3 + ui/src/App.tsx | 33 +- ui/src/components/DebugLogViewer.tsx | 301 ++++++++++--- ui/src/components/DevServerControl.tsx | 155 +++++++ ui/src/components/Terminal.tsx | 512 ++++++++++++++++++++++ ui/src/hooks/useWebSocket.ts | 35 +- ui/src/lib/api.ts | 32 ++ ui/src/lib/types.ts | 34 +- ui/tsconfig.tsbuildinfo | 2 +- 22 files changed, 3360 insertions(+), 66 deletions(-) create mode 100644 server/routers/devserver.py create mode 100644 server/routers/terminal.py create mode 100644 server/services/dev_server_manager.py create mode 100644 server/services/project_config.py create mode 100644 server/services/terminal_manager.py create mode 100644 ui/src/components/DevServerControl.tsx create mode 100644 ui/src/components/Terminal.tsx diff --git a/.gitignore b/.gitignore index dccad2d6..ce045c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ Pipfile.lock *.temp .tmp/ .temp/ +tmpclaude-*-cwd diff --git a/requirements.txt b/requirements.txt index 1ff89a79..0e260ba3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ websockets>=13.0 python-multipart>=0.0.17 psutil>=6.0.0 aiofiles>=24.0.0 +pywinpty>=2.0.0; sys_platform == "win32" # Dev dependencies ruff>=0.8.0 diff --git a/server/main.py b/server/main.py index 91b9875a..1c408682 100644 --- a/server/main.py +++ b/server/main.py @@ -34,17 +34,24 @@ def get_cli_command() -> str: from .routers import ( agent_router, assistant_chat_router, + devserver_router, expand_project_router, features_router, filesystem_router, projects_router, settings_router, spec_creation_router, + terminal_router, ) from .schemas import SetupStatus from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions +from .services.dev_server_manager import ( + cleanup_all_devservers, + cleanup_orphaned_devserver_locks, +) from .services.expand_chat_session import cleanup_all_expand_sessions from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks +from .services.terminal_manager import cleanup_all_terminals from .websocket import project_websocket # Paths @@ -57,11 +64,14 @@ async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown.""" # Startup - clean up orphaned lock files from previous runs cleanup_orphaned_locks() + cleanup_orphaned_devserver_locks() yield - # Shutdown - cleanup all running agents and sessions + # Shutdown - cleanup all running agents, sessions, terminals, and dev servers await cleanup_all_managers() await cleanup_assistant_sessions() await cleanup_all_expand_sessions() + await cleanup_all_terminals() + await cleanup_all_devservers() # Create FastAPI app @@ -110,11 +120,13 @@ async def require_localhost(request: Request, call_next): app.include_router(projects_router) app.include_router(features_router) app.include_router(agent_router) +app.include_router(devserver_router) app.include_router(spec_creation_router) app.include_router(expand_project_router) app.include_router(filesystem_router) app.include_router(assistant_chat_router) app.include_router(settings_router) +app.include_router(terminal_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 36b0fb59..763247fc 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -7,20 +7,24 @@ 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", ] diff --git a/server/routers/devserver.py b/server/routers/devserver.py new file mode 100644 index 00000000..673bc3ed --- /dev/null +++ b/server/routers/devserver.py @@ -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"], + ) diff --git a/server/routers/terminal.py b/server/routers/terminal.py new file mode 100644 index 00000000..196e69f2 --- /dev/null +++ b/server/routers/terminal.py @@ -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": ""} - Keyboard input + - {"type": "resize", "cols": 80, "rows": 24} - Terminal resize + - {"type": "ping"} - Keep-alive ping + + Server -> Client: + - {"type": "output", "data": ""} - 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" + ) diff --git a/server/schemas.py b/server/schemas.py index cb0a4ecc..72d6bf44 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -308,3 +308,61 @@ def validate_model(cls, v: str | None) -> str | None: if v is not None and v not in VALID_MODELS: raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") return v + + +# ============================================================================ +# Dev Server Schemas +# ============================================================================ + + +class DevServerStartRequest(BaseModel): + """Request schema for starting the dev server.""" + command: str | None = None # If None, uses effective command from config + + +class DevServerStatus(BaseModel): + """Current dev server status.""" + status: Literal["stopped", "running", "crashed"] + pid: int | None = None + url: str | None = None + command: str | None = None + started_at: datetime | None = None + + +class DevServerActionResponse(BaseModel): + """Response for dev server control actions.""" + success: bool + status: Literal["stopped", "running", "crashed"] + message: str = "" + + +class DevServerConfigResponse(BaseModel): + """Response for dev server configuration.""" + detected_type: str | None = None + detected_command: str | None = None + custom_command: str | None = None + effective_command: str | None = None + + +class DevServerConfigUpdate(BaseModel): + """Request schema for updating dev server configuration.""" + custom_command: str | None = None # None clears the custom command + + +# ============================================================================ +# Dev Server WebSocket Message Schemas +# ============================================================================ + + +class WSDevLogMessage(BaseModel): + """WebSocket message for dev server log output.""" + type: Literal["dev_log"] = "dev_log" + line: str + timestamp: datetime + + +class WSDevServerStatusMessage(BaseModel): + """WebSocket message for dev server status changes.""" + type: Literal["dev_server_status"] = "dev_server_status" + status: Literal["stopped", "running", "crashed"] + url: str | None = None diff --git a/server/services/__init__.py b/server/services/__init__.py index eb5c35c2..5fbbff61 100644 --- a/server/services/__init__.py +++ b/server/services/__init__.py @@ -6,5 +6,31 @@ """ from .process_manager import AgentProcessManager +from .project_config import ( + clear_dev_command, + detect_project_type, + get_default_dev_command, + get_dev_command, + get_project_config, + set_dev_command, +) +from .terminal_manager import ( + TerminalSession, + cleanup_all_terminals, + get_terminal_session, + remove_terminal_session, +) -__all__ = ["AgentProcessManager"] +__all__ = [ + "AgentProcessManager", + "TerminalSession", + "cleanup_all_terminals", + "clear_dev_command", + "detect_project_type", + "get_default_dev_command", + "get_dev_command", + "get_project_config", + "get_terminal_session", + "remove_terminal_session", + "set_dev_command", +] diff --git a/server/services/dev_server_manager.py b/server/services/dev_server_manager.py new file mode 100644 index 00000000..3ca5eb02 --- /dev/null +++ b/server/services/dev_server_manager.py @@ -0,0 +1,556 @@ +""" +Dev Server Process Manager +========================== + +Manages the lifecycle of dev server subprocesses per project. +Provides start/stop functionality with cross-platform support via psutil. + +This is a simplified version of AgentProcessManager, tailored for dev servers: +- No pause/resume (not needed for dev servers) +- URL detection from output (regex for http://localhost:XXXX patterns) +- Simpler status states: stopped, running, crashed +""" + +import asyncio +import logging +import re +import subprocess +import sys +import threading +from datetime import datetime +from pathlib import Path +from typing import Awaitable, Callable, Literal, Set + +import psutil + +from registry import list_registered_projects + +logger = logging.getLogger(__name__) + +# Patterns for sensitive data that should be redacted from output +SENSITIVE_PATTERNS = [ + r'sk-[a-zA-Z0-9]{20,}', # Anthropic API keys + r'ANTHROPIC_API_KEY=[^\s]+', + r'api[_-]?key[=:][^\s]+', + r'token[=:][^\s]+', + r'password[=:][^\s]+', + r'secret[=:][^\s]+', + r'ghp_[a-zA-Z0-9]{36,}', # GitHub personal access tokens + r'gho_[a-zA-Z0-9]{36,}', # GitHub OAuth tokens + r'ghs_[a-zA-Z0-9]{36,}', # GitHub server tokens + r'ghr_[a-zA-Z0-9]{36,}', # GitHub refresh tokens + r'aws[_-]?access[_-]?key[=:][^\s]+', # AWS keys + r'aws[_-]?secret[=:][^\s]+', +] + +# Patterns to detect URLs in dev server output +# Matches common patterns like: +# - http://localhost:3000 +# - http://127.0.0.1:5173 +# - https://localhost:8080/ +# - Local: http://localhost:3000 +# - http://localhost:3000/api/docs +URL_PATTERNS = [ + r'https?://(?:localhost|127\.0\.0\.1):\d+(?:/[^\s]*)?', + r'https?://\[::1\]:\d+(?:/[^\s]*)?', # IPv6 localhost + r'https?://0\.0\.0\.0:\d+(?:/[^\s]*)?', # Bound to all interfaces +] + + +def sanitize_output(line: str) -> str: + """Remove sensitive information from output lines.""" + for pattern in SENSITIVE_PATTERNS: + line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE) + return line + + +def extract_url(line: str) -> str | None: + """ + Extract a localhost URL from an output line if present. + + Returns the first URL found, or None if no URL is detected. + """ + for pattern in URL_PATTERNS: + match = re.search(pattern, line) + if match: + return match.group(0) + return None + + +class DevServerProcessManager: + """ + Manages dev server subprocess lifecycle for a single project. + + Provides start/stop with cross-platform support via psutil. + Supports multiple output callbacks for WebSocket clients. + Detects and tracks the server URL from output. + """ + + def __init__( + self, + project_name: str, + project_dir: Path, + ): + """ + Initialize the dev server process manager. + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + """ + self.project_name = project_name + self.project_dir = project_dir + self.process: subprocess.Popen | None = None + self._status: Literal["stopped", "running", "crashed"] = "stopped" + self.started_at: datetime | None = None + self._output_task: asyncio.Task | None = None + self._detected_url: str | None = None + self._command: str | None = None # Store the command used to start + + # Support multiple callbacks (for multiple WebSocket clients) + self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set() + self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set() + self._callbacks_lock = threading.Lock() + + # Lock file to prevent multiple instances (stored in project directory) + self.lock_file = self.project_dir / ".devserver.lock" + + @property + def status(self) -> Literal["stopped", "running", "crashed"]: + """Current status of the dev server.""" + return self._status + + @status.setter + def status(self, value: Literal["stopped", "running", "crashed"]): + old_status = self._status + self._status = value + if old_status != value: + self._notify_status_change(value) + + @property + def detected_url(self) -> str | None: + """The URL detected from server output, if any.""" + return self._detected_url + + @property + def pid(self) -> int | None: + """Process ID of the running dev server, or None if not running.""" + return self.process.pid if self.process else None + + def _notify_status_change(self, status: str) -> None: + """Notify all registered callbacks of status change.""" + with self._callbacks_lock: + callbacks = list(self._status_callbacks) + + for callback in callbacks: + try: + # Schedule the callback in the event loop + loop = asyncio.get_running_loop() + loop.create_task(self._safe_callback(callback, status)) + except RuntimeError: + # No running event loop + pass + + async def _safe_callback(self, callback: Callable, *args) -> None: + """Safely execute a callback, catching and logging any errors.""" + try: + await callback(*args) + except Exception as e: + logger.warning(f"Callback error: {e}") + + def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None: + """Add a callback for output lines.""" + with self._callbacks_lock: + self._output_callbacks.add(callback) + + def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None: + """Remove an output callback.""" + with self._callbacks_lock: + self._output_callbacks.discard(callback) + + def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None: + """Add a callback for status changes.""" + with self._callbacks_lock: + self._status_callbacks.add(callback) + + def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None: + """Remove a status callback.""" + with self._callbacks_lock: + self._status_callbacks.discard(callback) + + def _check_lock(self) -> bool: + """ + Check if another dev server is already running for this project. + + Validates that the PID in the lock file belongs to a process running + in the same project directory to avoid false positives from PID recycling. + + Returns: + True if we can proceed (no other server running), False otherwise. + """ + if not self.lock_file.exists(): + return True + + try: + pid = int(self.lock_file.read_text().strip()) + if psutil.pid_exists(pid): + try: + proc = psutil.Process(pid) + if proc.is_running(): + try: + # Verify the process is running in our project directory + # to avoid false positives from PID recycling + proc_cwd = Path(proc.cwd()).resolve() + if sys.platform == "win32": + # Windows paths are case-insensitive + if proc_cwd.as_posix().lower() == self.project_dir.resolve().as_posix().lower(): + return False # Likely our dev server + else: + if proc_cwd == self.project_dir.resolve(): + return False # Likely our dev server + except (psutil.AccessDenied, OSError): + # Cannot verify cwd, assume it's our process to be safe + return False + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + # Stale lock file - process no longer exists or is in different directory + self.lock_file.unlink(missing_ok=True) + return True + except (ValueError, OSError): + # Invalid lock file content - remove it + self.lock_file.unlink(missing_ok=True) + return True + + def _create_lock(self) -> None: + """Create lock file with current process PID.""" + self.lock_file.parent.mkdir(parents=True, exist_ok=True) + if self.process: + self.lock_file.write_text(str(self.process.pid)) + + def _remove_lock(self) -> None: + """Remove lock file.""" + self.lock_file.unlink(missing_ok=True) + + async def _broadcast_output(self, line: str) -> None: + """Broadcast output line to all registered callbacks.""" + with self._callbacks_lock: + callbacks = list(self._output_callbacks) + + for callback in callbacks: + await self._safe_callback(callback, line) + + async def _stream_output(self) -> None: + """Stream process output to callbacks and detect URL.""" + if not self.process or not self.process.stdout: + return + + try: + loop = asyncio.get_running_loop() + while True: + # Use run_in_executor for blocking readline + line = await loop.run_in_executor( + None, self.process.stdout.readline + ) + if not line: + break + + decoded = line.decode("utf-8", errors="replace").rstrip() + sanitized = sanitize_output(decoded) + + # Try to detect URL from output (only if not already detected) + if not self._detected_url: + url = extract_url(decoded) + if url: + self._detected_url = url + logger.info( + "Dev server URL detected for %s: %s", + self.project_name, url + ) + + await self._broadcast_output(sanitized) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Output streaming error: {e}") + finally: + # Check if process ended + if self.process and self.process.poll() is not None: + exit_code = self.process.returncode + if exit_code != 0 and self.status == "running": + self.status = "crashed" + elif self.status == "running": + self.status = "stopped" + self._remove_lock() + + async def start(self, command: str) -> tuple[bool, str]: + """ + Start the dev server as a subprocess. + + Args: + command: The shell command to run (e.g., "npm run dev") + + Returns: + Tuple of (success, message) + """ + if self.status == "running": + return False, "Dev server is already running" + + if not self._check_lock(): + return False, "Another dev server instance is already running for this project" + + # Validate that project directory exists + if not self.project_dir.exists(): + return False, f"Project directory does not exist: {self.project_dir}" + + self._command = command + self._detected_url = None # Reset URL detection + + try: + # Determine shell based on platform + if sys.platform == "win32": + # On Windows, use cmd.exe + shell_cmd = ["cmd", "/c", command] + else: + # On Unix-like systems, use sh + shell_cmd = ["sh", "-c", command] + + # Start subprocess with piped stdout/stderr + # stdin=DEVNULL prevents interactive dev servers from blocking on stdin + # On Windows, use CREATE_NO_WINDOW to prevent console window from flashing + if sys.platform == "win32": + self.process = subprocess.Popen( + shell_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=str(self.project_dir), + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + self.process = subprocess.Popen( + shell_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=str(self.project_dir), + ) + + self._create_lock() + self.started_at = datetime.now() + self.status = "running" + + # Start output streaming task + self._output_task = asyncio.create_task(self._stream_output()) + + return True, f"Dev server started with PID {self.process.pid}" + except Exception as e: + logger.exception("Failed to start dev server") + return False, f"Failed to start dev server: {e}" + + async def stop(self) -> tuple[bool, str]: + """ + Stop the dev server (SIGTERM then SIGKILL if needed). + + Uses psutil to terminate the entire process tree, ensuring + child processes (like Node.js) are also terminated. + + Returns: + Tuple of (success, message) + """ + if not self.process or self.status == "stopped": + return False, "Dev server is not running" + + try: + # Cancel output streaming + if self._output_task: + self._output_task.cancel() + try: + await self._output_task + except asyncio.CancelledError: + pass + + # Use psutil to terminate the entire process tree + # This is important for dev servers that spawn child processes + try: + parent = psutil.Process(self.process.pid) + children = parent.children(recursive=True) + + # Terminate children first + for child in children: + try: + child.terminate() + except psutil.NoSuchProcess: + pass + + # Terminate parent + parent.terminate() + + # Wait for graceful shutdown + _, still_alive = psutil.wait_procs( + [parent] + children, timeout=5 + ) + + # Force kill any remaining processes + for proc in still_alive: + try: + proc.kill() + except psutil.NoSuchProcess: + pass + + except psutil.NoSuchProcess: + # Process already gone + pass + + self._remove_lock() + self.status = "stopped" + self.process = None + self.started_at = None + self._detected_url = None + self._command = None + + return True, "Dev server stopped" + except Exception as e: + logger.exception("Failed to stop dev server") + return False, f"Failed to stop dev server: {e}" + + async def healthcheck(self) -> bool: + """ + Check if the dev server process is still alive. + + Updates status to 'crashed' if process has died unexpectedly. + + Returns: + True if healthy, False otherwise + """ + if not self.process: + return self.status == "stopped" + + poll = self.process.poll() + if poll is not None: + # Process has terminated + if self.status == "running": + self.status = "crashed" + self._remove_lock() + return False + + return True + + def get_status_dict(self) -> dict: + """Get current status as a dictionary.""" + return { + "status": self.status, + "pid": self.pid, + "started_at": self.started_at.isoformat() if self.started_at else None, + "detected_url": self._detected_url, + "command": self._command, + } + + +# Global registry of dev server managers per project with thread safety +_managers: dict[str, DevServerProcessManager] = {} +_managers_lock = threading.Lock() + + +def get_devserver_manager(project_name: str, project_dir: Path) -> DevServerProcessManager: + """ + Get or create a dev server process manager for a project (thread-safe). + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + + Returns: + DevServerProcessManager instance for the project + """ + with _managers_lock: + if project_name in _managers: + manager = _managers[project_name] + # Update project_dir in case project was moved + if manager.project_dir.resolve() != project_dir.resolve(): + logger.info( + f"Project {project_name} path updated: {manager.project_dir} -> {project_dir}" + ) + manager.project_dir = project_dir + manager.lock_file = project_dir / ".devserver.lock" + return manager + _managers[project_name] = DevServerProcessManager(project_name, project_dir) + return _managers[project_name] + + +async def cleanup_all_devservers() -> None: + """Stop all running dev servers. Called on server shutdown.""" + with _managers_lock: + managers = list(_managers.values()) + + for manager in managers: + try: + if manager.status != "stopped": + await manager.stop() + except Exception as e: + logger.warning(f"Error stopping dev server for {manager.project_name}: {e}") + + with _managers_lock: + _managers.clear() + + +def cleanup_orphaned_devserver_locks() -> int: + """ + Clean up orphaned dev server lock files from previous server runs. + + Scans all registered projects for .devserver.lock files and removes them + if the referenced process is no longer running. + + Returns: + Number of orphaned lock files cleaned up + """ + cleaned = 0 + try: + projects = list_registered_projects() + for name, info in projects.items(): + project_path = Path(info.get("path", "")) + if not project_path.exists(): + continue + + lock_file = project_path / ".devserver.lock" + if not lock_file.exists(): + continue + + try: + pid_str = lock_file.read_text().strip() + pid = int(pid_str) + + # Check if process is still running + if psutil.pid_exists(pid): + try: + proc = psutil.Process(pid) + if proc.is_running(): + # Process is still running, don't remove + logger.info( + "Found running dev server for project '%s' (PID %d)", + name, pid + ) + continue + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Process not running - remove stale lock + lock_file.unlink(missing_ok=True) + cleaned += 1 + logger.info("Removed orphaned dev server lock file for project '%s'", name) + + except (ValueError, OSError) as e: + # Invalid lock file content - remove it + logger.warning( + "Removing invalid dev server lock file for project '%s': %s", name, e + ) + lock_file.unlink(missing_ok=True) + cleaned += 1 + + except Exception as e: + logger.error("Error during dev server orphan cleanup: %s", e) + + if cleaned: + logger.info("Cleaned up %d orphaned dev server lock file(s)", cleaned) + + return cleaned diff --git a/server/services/project_config.py b/server/services/project_config.py new file mode 100644 index 00000000..f6e50d07 --- /dev/null +++ b/server/services/project_config.py @@ -0,0 +1,466 @@ +""" +Project Configuration Service +============================= + +Handles project type detection and dev command configuration. +Detects project types by scanning for configuration files and provides +default or custom dev commands for each project. + +Configuration is stored in {project_dir}/.autocoder/config.json. +""" + +import json +import logging +from pathlib import Path +from typing import TypedDict + +# Python 3.11+ has tomllib in the standard library +try: + import tomllib +except ImportError: + tomllib = None # type: ignore[assignment] + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Path Validation +# ============================================================================= + + +def _validate_project_dir(project_dir: Path) -> Path: + """ + Validate and resolve the project directory. + + Args: + project_dir: Path to the project directory. + + Returns: + Resolved Path object. + + Raises: + ValueError: If project_dir is not a valid directory. + """ + resolved = Path(project_dir).resolve() + + if not resolved.exists(): + raise ValueError(f"Project directory does not exist: {resolved}") + if not resolved.is_dir(): + raise ValueError(f"Path is not a directory: {resolved}") + + return resolved + +# ============================================================================= +# Type Definitions +# ============================================================================= + + +class ProjectConfig(TypedDict): + """Full project configuration response.""" + detected_type: str | None + detected_command: str | None + custom_command: str | None + effective_command: str | None + + +# ============================================================================= +# Project Type Definitions +# ============================================================================= + +# Mapping of project types to their default dev commands +PROJECT_TYPE_COMMANDS: dict[str, str] = { + "nodejs-vite": "npm run dev", + "nodejs-cra": "npm start", + "python-poetry": "poetry run python -m uvicorn main:app --reload", + "python-django": "python manage.py runserver", + "python-fastapi": "python -m uvicorn main:app --reload", + "rust": "cargo run", + "go": "go run .", +} + + +# ============================================================================= +# Configuration File Handling +# ============================================================================= + + +def _get_config_path(project_dir: Path) -> Path: + """ + Get the path to the project config file. + + Args: + project_dir: Path to the project directory. + + Returns: + Path to the .autocoder/config.json file. + """ + return project_dir / ".autocoder" / "config.json" + + +def _load_config(project_dir: Path) -> dict: + """ + Load the project configuration from disk. + + Args: + project_dir: Path to the project directory. + + Returns: + Configuration dictionary, or empty dict if file doesn't exist or is invalid. + """ + config_path = _get_config_path(project_dir) + + if not config_path.exists(): + return {} + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + if not isinstance(config, dict): + logger.warning( + "Invalid config format in %s: expected dict, got %s", + config_path, type(config).__name__ + ) + return {} + + return config + + except json.JSONDecodeError as e: + logger.warning("Failed to parse config at %s: %s", config_path, e) + return {} + except OSError as e: + logger.warning("Failed to read config at %s: %s", config_path, e) + return {} + + +def _save_config(project_dir: Path, config: dict) -> None: + """ + Save the project configuration to disk. + + Creates the .autocoder directory if it doesn't exist. + + Args: + project_dir: Path to the project directory. + config: Configuration dictionary to save. + + Raises: + OSError: If the file cannot be written. + """ + config_path = _get_config_path(project_dir) + + # Ensure the .autocoder directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + logger.debug("Saved config to %s", config_path) + except OSError as e: + logger.error("Failed to save config to %s: %s", config_path, e) + raise + + +# ============================================================================= +# Project Type Detection +# ============================================================================= + + +def _parse_package_json(project_dir: Path) -> dict | None: + """ + Parse package.json if it exists. + + Args: + project_dir: Path to the project directory. + + Returns: + Parsed package.json as dict, or None if not found or invalid. + """ + package_json_path = project_dir / "package.json" + + if not package_json_path.exists(): + return None + + try: + with open(package_json_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + return None + except (json.JSONDecodeError, OSError) as e: + logger.debug("Failed to parse package.json in %s: %s", project_dir, e) + return None + + +def _is_poetry_project(project_dir: Path) -> bool: + """ + Check if pyproject.toml indicates a Poetry project. + + Parses pyproject.toml to look for [tool.poetry] section. + Falls back to simple file existence check if tomllib is not available. + + Args: + project_dir: Path to the project directory. + + Returns: + True if pyproject.toml exists and contains Poetry configuration. + """ + pyproject_path = project_dir / "pyproject.toml" + if not pyproject_path.exists(): + return False + + # If tomllib is available (Python 3.11+), parse and check for [tool.poetry] + if tomllib is not None: + try: + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + return "poetry" in data.get("tool", {}) + except Exception: + # If parsing fails, fall back to False + return False + + # Fallback for older Python: simple file existence check + # This is less accurate but provides backward compatibility + return True + + +def detect_project_type(project_dir: Path) -> str | None: + """ + Detect the project type by scanning for configuration files. + + Detection priority (first match wins): + 1. package.json with scripts.dev -> nodejs-vite + 2. package.json with scripts.start -> nodejs-cra + 3. pyproject.toml with [tool.poetry] -> python-poetry + 4. manage.py -> python-django + 5. requirements.txt + (main.py or app.py) -> python-fastapi + 6. Cargo.toml -> rust + 7. go.mod -> go + + Args: + project_dir: Path to the project directory. + + Returns: + Project type string (e.g., "nodejs-vite", "python-django"), + or None if no known project type is detected. + """ + project_dir = Path(project_dir).resolve() + + if not project_dir.exists() or not project_dir.is_dir(): + logger.debug("Project directory does not exist: %s", project_dir) + return None + + # Check for Node.js projects (package.json) + package_json = _parse_package_json(project_dir) + if package_json is not None: + scripts = package_json.get("scripts", {}) + if isinstance(scripts, dict): + # Check for 'dev' script first (typical for Vite, Next.js, etc.) + if "dev" in scripts: + logger.debug("Detected nodejs-vite project in %s", project_dir) + return "nodejs-vite" + # Fall back to 'start' script (typical for CRA) + if "start" in scripts: + logger.debug("Detected nodejs-cra project in %s", project_dir) + return "nodejs-cra" + + # Check for Python Poetry project (must have [tool.poetry] in pyproject.toml) + if _is_poetry_project(project_dir): + logger.debug("Detected python-poetry project in %s", project_dir) + return "python-poetry" + + # Check for Django project + if (project_dir / "manage.py").exists(): + logger.debug("Detected python-django project in %s", project_dir) + return "python-django" + + # Check for Python FastAPI project (requirements.txt + main.py or app.py) + if (project_dir / "requirements.txt").exists(): + has_main = (project_dir / "main.py").exists() + has_app = (project_dir / "app.py").exists() + if has_main or has_app: + logger.debug("Detected python-fastapi project in %s", project_dir) + return "python-fastapi" + + # Check for Rust project + if (project_dir / "Cargo.toml").exists(): + logger.debug("Detected rust project in %s", project_dir) + return "rust" + + # Check for Go project + if (project_dir / "go.mod").exists(): + logger.debug("Detected go project in %s", project_dir) + return "go" + + logger.debug("No known project type detected in %s", project_dir) + return None + + +# ============================================================================= +# Dev Command Functions +# ============================================================================= + + +def get_default_dev_command(project_dir: Path) -> str | None: + """ + Get the auto-detected dev command for a project. + + This returns the default command based on detected project type, + ignoring any custom command that may be configured. + + Args: + project_dir: Path to the project directory. + + Returns: + Default dev command string for the detected project type, + or None if no project type is detected. + """ + project_type = detect_project_type(project_dir) + + if project_type is None: + return None + + return PROJECT_TYPE_COMMANDS.get(project_type) + + +def get_dev_command(project_dir: Path) -> str | None: + """ + Get the effective dev command for a project. + + Returns the custom command if one is configured, + otherwise returns the auto-detected default command. + + Args: + project_dir: Path to the project directory. + + Returns: + The effective dev command (custom if set, else detected), + or None if neither is available. + """ + project_dir = Path(project_dir).resolve() + + # Check for custom command first + config = _load_config(project_dir) + custom_command = config.get("dev_command") + + if custom_command and isinstance(custom_command, str): + # Type is narrowed to str by isinstance check + result: str = custom_command + return result + + # Fall back to auto-detected command + return get_default_dev_command(project_dir) + + +def set_dev_command(project_dir: Path, command: str) -> None: + """ + Save a custom dev command for a project. + + Args: + project_dir: Path to the project directory. + command: The custom dev command to save. + + Raises: + ValueError: If command is empty or not a string, or if project_dir is invalid. + OSError: If the config file cannot be written. + """ + if not command or not isinstance(command, str): + raise ValueError("Command must be a non-empty string") + + project_dir = _validate_project_dir(project_dir) + + # Load existing config and update + config = _load_config(project_dir) + config["dev_command"] = command + + _save_config(project_dir, config) + logger.info("Set custom dev command for %s: %s", project_dir.name, command) + + +def clear_dev_command(project_dir: Path) -> None: + """ + Remove the custom dev command, reverting to auto-detection. + + If no config file exists or no custom command is set, + this function does nothing (no error is raised). + + Args: + project_dir: Path to the project directory. + + Raises: + ValueError: If project_dir is not a valid directory. + """ + project_dir = _validate_project_dir(project_dir) + config_path = _get_config_path(project_dir) + + if not config_path.exists(): + return + + config = _load_config(project_dir) + + if "dev_command" not in config: + return + + del config["dev_command"] + + # If config is now empty, delete the file + if not config: + try: + config_path.unlink(missing_ok=True) + logger.info("Removed empty config file for %s", project_dir.name) + + # Also remove .autocoder directory if empty + autocoder_dir = config_path.parent + if autocoder_dir.exists() and not any(autocoder_dir.iterdir()): + autocoder_dir.rmdir() + logger.debug("Removed empty .autocoder directory for %s", project_dir.name) + except OSError as e: + logger.warning("Failed to clean up config for %s: %s", project_dir.name, e) + else: + _save_config(project_dir, config) + + logger.info("Cleared custom dev command for %s", project_dir.name) + + +def get_project_config(project_dir: Path) -> ProjectConfig: + """ + Get the full project configuration including detection results. + + This provides all relevant configuration information in a single call, + useful for displaying in a UI or debugging. + + Args: + project_dir: Path to the project directory. + + Returns: + ProjectConfig dict with: + - detected_type: The auto-detected project type (or None) + - detected_command: The default command for detected type (or None) + - custom_command: The user-configured custom command (or None) + - effective_command: The command that would actually be used (or None) + + Raises: + ValueError: If project_dir is not a valid directory. + """ + project_dir = _validate_project_dir(project_dir) + + # Detect project type and get default command + detected_type = detect_project_type(project_dir) + detected_command = PROJECT_TYPE_COMMANDS.get(detected_type) if detected_type else None + + # Load custom command from config + config = _load_config(project_dir) + custom_command = config.get("dev_command") + + # Validate custom_command is a string + if not isinstance(custom_command, str): + custom_command = None + + # Determine effective command + effective_command = custom_command if custom_command else detected_command + + return ProjectConfig( + detected_type=detected_type, + detected_command=detected_command, + custom_command=custom_command, + effective_command=effective_command, + ) diff --git a/server/services/terminal_manager.py b/server/services/terminal_manager.py new file mode 100644 index 00000000..f9264ffb --- /dev/null +++ b/server/services/terminal_manager.py @@ -0,0 +1,563 @@ +""" +Terminal Manager +================ + +Manages PTY terminal sessions per project with cross-platform support. +Uses winpty (ConPTY) on Windows and built-in pty module on Unix. +""" + +import asyncio +import logging +import os +import platform +import shutil +import threading +from pathlib import Path +from typing import Callable, Set + +logger = logging.getLogger(__name__) + +# Platform detection +IS_WINDOWS = platform.system() == "Windows" + +# Conditional imports for PTY support +# Note: Type checking is disabled for cross-platform PTY modules since mypy +# cannot properly handle conditional imports for platform-specific APIs. +if IS_WINDOWS: + try: + from winpty import PtyProcess as WinPtyProcess + + WINPTY_AVAILABLE = True + except ImportError: + WinPtyProcess = None + WINPTY_AVAILABLE = False + logger.warning( + "winpty package not installed. Terminal sessions will not be available on Windows. " + "Install with: pip install pywinpty" + ) +else: + # Unix systems use built-in pty module + import fcntl + import pty + import select + import signal + import struct + import termios + + WINPTY_AVAILABLE = False # Not applicable on Unix + + +def _get_shell() -> str: + """ + Get the appropriate shell for the current platform. + + Returns: + Path to shell executable + """ + if IS_WINDOWS: + # Prefer PowerShell, fall back to cmd.exe + powershell = shutil.which("powershell.exe") + if powershell: + return powershell + cmd = shutil.which("cmd.exe") + if cmd: + return cmd + # Last resort fallback + return "cmd.exe" + else: + # Unix: Use $SHELL environment variable or fall back to /bin/bash + shell = os.environ.get("SHELL") + if shell and shutil.which(shell): + return shell + # Fall back to common shells + for fallback in ["/bin/bash", "/bin/sh"]: + if os.path.exists(fallback): + return fallback + return "/bin/sh" + + +class TerminalSession: + """ + Manages a single PTY terminal session for a project. + + Provides cross-platform PTY support with async output streaming + and multiple output callbacks for WebSocket clients. + """ + + def __init__(self, project_name: str, project_dir: Path): + """ + Initialize the terminal session. + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory (used as cwd) + """ + self.project_name = project_name + self.project_dir = project_dir + + # PTY process references (platform-specific) + self._pty_process: "WinPtyProcess | None" = None # Windows winpty + self._master_fd: int | None = None # Unix master file descriptor + self._child_pid: int | None = None # Unix child process PID + + # State tracking + self._is_active = False + self._output_task: asyncio.Task | None = None + + # Output callbacks with thread-safe access + self._output_callbacks: Set[Callable[[bytes], None]] = set() + self._callbacks_lock = threading.Lock() + + @property + def is_active(self) -> bool: + """Check if the terminal session is currently active.""" + return self._is_active + + @property + def pid(self) -> int | None: + """Get the PID of the PTY child process.""" + if IS_WINDOWS: + if self._pty_process is not None: + try: + pid = self._pty_process.pid + return int(pid) if pid is not None else None + except Exception: + return None + return None + else: + return self._child_pid + + def add_output_callback(self, callback: Callable[[bytes], None]) -> None: + """ + Add a callback to receive terminal output. + + Args: + callback: Function that receives raw bytes from the PTY + """ + with self._callbacks_lock: + self._output_callbacks.add(callback) + + def remove_output_callback(self, callback: Callable[[bytes], None]) -> None: + """ + Remove an output callback. + + Args: + callback: The callback to remove + """ + with self._callbacks_lock: + self._output_callbacks.discard(callback) + + def _broadcast_output(self, data: bytes) -> None: + """Broadcast output data to all registered callbacks.""" + with self._callbacks_lock: + callbacks = list(self._output_callbacks) + + for callback in callbacks: + try: + callback(data) + except Exception as e: + logger.warning(f"Output callback error: {e}") + + async def start(self, cols: int = 80, rows: int = 24) -> bool: + """ + Start the PTY terminal session. + + Args: + cols: Terminal width in columns + rows: Terminal height in rows + + Returns: + True if started successfully, False otherwise + """ + if self._is_active: + logger.warning(f"Terminal session already active for {self.project_name}") + return False + + # Validate project directory + if not self.project_dir.exists(): + logger.error(f"Project directory does not exist: {self.project_dir}") + return False + if not self.project_dir.is_dir(): + logger.error(f"Project path is not a directory: {self.project_dir}") + return False + + shell = _get_shell() + cwd = str(self.project_dir.resolve()) + + try: + if IS_WINDOWS: + return await self._start_windows(shell, cwd, cols, rows) + else: + return await self._start_unix(shell, cwd, cols, rows) + except Exception as e: + logger.exception(f"Failed to start terminal for {self.project_name}: {e}") + return False + + async def _start_windows(self, shell: str, cwd: str, cols: int, rows: int) -> bool: + """Start PTY on Windows using winpty.""" + if not WINPTY_AVAILABLE: + logger.error("Cannot start terminal: winpty package not available") + # This error will be caught and sent to the client + raise RuntimeError( + "Terminal requires pywinpty on Windows. Install with: pip install pywinpty" + ) + + try: + # WinPtyProcess.spawn expects the shell command + self._pty_process = WinPtyProcess.spawn( + shell, + cwd=cwd, + dimensions=(rows, cols), + ) + self._is_active = True + + # Start output reading task + self._output_task = asyncio.create_task(self._read_output_windows()) + + logger.info(f"Terminal started for {self.project_name} (PID: {self.pid}, shell: {shell})") + return True + + except Exception as e: + logger.exception(f"Failed to start Windows PTY: {e}") + self._pty_process = None + return False + + async def _start_unix(self, shell: str, cwd: str, cols: int, rows: int) -> bool: + """Start PTY on Unix using built-in pty module.""" + # Note: This entire method uses Unix-specific APIs that don't exist on Windows. + # Type checking is disabled for these platform-specific calls. + try: + # Fork a new pseudo-terminal + pid, master_fd = pty.fork() # type: ignore[attr-defined] + + if pid == 0: + # Child process - exec the shell + os.chdir(cwd) + # Set terminal size (Unix-specific modules imported at top-level) + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(0, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined] + + # Execute the shell + os.execvp(shell, [shell]) + os._exit(1) # Fallback if execvp returns (shouldn't happen) + else: + # Parent process + self._master_fd = master_fd + self._child_pid = pid + self._is_active = True + + # Set terminal size on master (Unix-specific modules imported at top-level) + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined] + + # Start output reading task + self._output_task = asyncio.create_task(self._read_output_unix()) + + logger.info(f"Terminal started for {self.project_name} (PID: {pid}, shell: {shell})") + return True + + except Exception as e: + logger.exception(f"Failed to start Unix PTY: {e}") + self._master_fd = None + self._child_pid = None + return False + + async def _read_output_windows(self) -> None: + """Read output from Windows PTY and broadcast to callbacks.""" + pty = self._pty_process + if pty is None: + return + + loop = asyncio.get_running_loop() + + def read_data(): + """Read data from PTY, capturing pty reference to avoid race condition.""" + try: + if pty.isalive(): + return pty.read(4096) + except Exception: + pass + return b"" + + try: + while self._is_active and self._pty_process is not None: + try: + # Use run_in_executor for non-blocking read + # winpty read() is blocking, so we need to run it in executor + data = await loop.run_in_executor(None, read_data) + + if data: + # winpty may return string, convert to bytes if needed + if isinstance(data, str): + data = data.encode("utf-8", errors="replace") + self._broadcast_output(data) + else: + # Check if process is still alive + if self._pty_process is None or not self._pty_process.isalive(): + break + # Small delay to prevent busy loop + await asyncio.sleep(0.01) + + except asyncio.CancelledError: + raise + except Exception as e: + if self._is_active: + logger.warning(f"Windows PTY read error: {e}") + break + + except asyncio.CancelledError: + pass + finally: + if self._is_active: + self._is_active = False + logger.info(f"Terminal output stream ended for {self.project_name}") + + async def _read_output_unix(self) -> None: + """Read output from Unix PTY and broadcast to callbacks.""" + if self._master_fd is None: + return + + loop = asyncio.get_running_loop() + + try: + while self._is_active and self._master_fd is not None: + try: + # Use run_in_executor with select for non-blocking read + def read_with_select(): + if self._master_fd is None: + return b"" + try: + # Wait up to 100ms for data + readable, _, _ = select.select([self._master_fd], [], [], 0.1) + if readable: + return os.read(self._master_fd, 4096) + return b"" + except (OSError, ValueError): + return b"" + + data = await loop.run_in_executor(None, read_with_select) + + if data: + self._broadcast_output(data) + elif not self._check_child_alive(): + break + + except asyncio.CancelledError: + raise + except Exception as e: + if self._is_active: + logger.warning(f"Unix PTY read error: {e}") + break + + except asyncio.CancelledError: + pass + finally: + if self._is_active: + self._is_active = False + logger.info(f"Terminal output stream ended for {self.project_name}") + # Reap zombie if not already reaped + if self._child_pid is not None: + try: + os.waitpid(self._child_pid, os.WNOHANG) + except ChildProcessError: + pass + except Exception: + pass + + def _check_child_alive(self) -> bool: + """Check if the Unix child process is still alive.""" + if self._child_pid is None: + return False + try: + # Use signal 0 to check if process exists without reaping it. + # This avoids race conditions with os.waitpid which can reap the process. + os.kill(self._child_pid, 0) + return True + except OSError: + return False + + def write(self, data: bytes) -> None: + """ + Write input data to the PTY. + + Args: + data: Raw bytes to write to the terminal + """ + if not self._is_active: + logger.warning(f"Cannot write to inactive terminal for {self.project_name}") + return + + try: + if IS_WINDOWS: + if self._pty_process is not None: + # winpty expects string input + text = data.decode("utf-8", errors="replace") + self._pty_process.write(text) + else: + if self._master_fd is not None: + os.write(self._master_fd, data) + except Exception as e: + logger.warning(f"Failed to write to PTY: {e}") + + def resize(self, cols: int, rows: int) -> None: + """ + Resize the terminal. + + Args: + cols: New terminal width in columns + rows: New terminal height in rows + """ + if not self._is_active: + return + + try: + if IS_WINDOWS: + if self._pty_process is not None: + self._pty_process.setwinsize(rows, cols) + else: + if self._master_fd is not None: + # Unix-specific modules imported at top-level + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined] + + logger.debug(f"Terminal resized for {self.project_name}: {cols}x{rows}") + except Exception as e: + logger.warning(f"Failed to resize terminal: {e}") + + async def stop(self) -> None: + """Stop the terminal session and clean up resources.""" + if not self._is_active: + return + + self._is_active = False + + # Cancel output reading task + if self._output_task is not None: + self._output_task.cancel() + try: + await self._output_task + except asyncio.CancelledError: + pass + self._output_task = None + + try: + if IS_WINDOWS: + await self._stop_windows() + else: + await self._stop_unix() + except Exception as e: + logger.warning(f"Error stopping terminal: {e}") + + logger.info(f"Terminal stopped for {self.project_name}") + + async def _stop_windows(self) -> None: + """Stop Windows PTY process.""" + if self._pty_process is None: + return + + try: + if self._pty_process.isalive(): + self._pty_process.terminate() + # Give it a moment to terminate + await asyncio.sleep(0.1) + if self._pty_process.isalive(): + self._pty_process.kill() + except Exception as e: + logger.warning(f"Error terminating Windows PTY: {e}") + finally: + self._pty_process = None + + async def _stop_unix(self) -> None: + """Stop Unix PTY process.""" + # Note: This method uses Unix-specific signal handling (signal imported at top-level) + + # Close master file descriptor + if self._master_fd is not None: + try: + os.close(self._master_fd) + except OSError: + pass + self._master_fd = None + + # Terminate child process + if self._child_pid is not None: + try: + os.kill(self._child_pid, signal.SIGTERM) + # Wait briefly for graceful shutdown + await asyncio.sleep(0.1) + # Check if still running and force kill if needed + try: + os.kill(self._child_pid, 0) # Check if process exists + # SIGKILL is Unix-specific (Windows would use SIGTERM) + os.kill(self._child_pid, signal.SIGKILL) # type: ignore[attr-defined] + except ProcessLookupError: + pass # Already terminated + # Reap the child process to prevent zombie + try: + os.waitpid(self._child_pid, 0) + except ChildProcessError: + pass + except ProcessLookupError: + pass # Already terminated + except Exception as e: + logger.warning(f"Error terminating Unix PTY child: {e}") + finally: + self._child_pid = None + + +# Global registry of terminal sessions per project with thread safety +_sessions: dict[str, TerminalSession] = {} +_sessions_lock = threading.Lock() + + +def get_terminal_session(project_name: str, project_dir: Path) -> TerminalSession: + """ + Get or create a terminal session for a project (thread-safe). + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + + Returns: + TerminalSession instance for the project + """ + with _sessions_lock: + if project_name not in _sessions: + _sessions[project_name] = TerminalSession(project_name, project_dir) + return _sessions[project_name] + + +def remove_terminal_session(project_name: str) -> TerminalSession | None: + """ + Remove a terminal session from the registry. + + Args: + project_name: Name of the project + + Returns: + The removed session, or None if not found + """ + with _sessions_lock: + return _sessions.pop(project_name, None) + + +async def cleanup_all_terminals() -> None: + """ + Stop all active terminal sessions. + + Called on server shutdown to ensure all PTY processes are terminated. + """ + with _sessions_lock: + sessions = list(_sessions.values()) + + for session in sessions: + try: + if session.is_active: + await session.stop() + except Exception as e: + logger.warning(f"Error stopping terminal for {session.project_name}: {e}") + + with _sessions_lock: + _sessions.clear() + + logger.info("All terminal sessions cleaned up") diff --git a/server/websocket.py b/server/websocket.py index 23139009..e987cfb7 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -2,7 +2,7 @@ WebSocket Handlers ================== -Real-time updates for project progress and agent output. +Real-time updates for project progress, agent output, and dev server output. """ import asyncio @@ -15,6 +15,7 @@ from fastapi import WebSocket, WebSocketDisconnect +from .services.dev_server_manager import get_devserver_manager from .services.process_manager import get_manager # Lazy imports @@ -195,16 +196,52 @@ async def on_status_change(status: str): agent_manager.add_output_callback(on_output) agent_manager.add_status_callback(on_status_change) + # Get dev server manager and register callbacks + devserver_manager = get_devserver_manager(project_name, project_dir) + + async def on_dev_output(line: str): + """Handle dev server output - broadcast to this WebSocket.""" + try: + await websocket.send_json({ + "type": "dev_log", + "line": line, + "timestamp": datetime.now().isoformat(), + }) + except Exception: + pass # Connection may be closed + + async def on_dev_status_change(status: str): + """Handle dev server status change - broadcast to this WebSocket.""" + try: + await websocket.send_json({ + "type": "dev_server_status", + "status": status, + "url": devserver_manager.detected_url, + }) + except Exception: + pass # Connection may be closed + + # Register dev server callbacks + devserver_manager.add_output_callback(on_dev_output) + devserver_manager.add_status_callback(on_dev_status_change) + # Start progress polling task poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir)) try: - # Send initial status + # Send initial agent status await websocket.send_json({ "type": "agent_status", "status": agent_manager.status, }) + # Send initial dev server status + await websocket.send_json({ + "type": "dev_server_status", + "status": devserver_manager.status, + "url": devserver_manager.detected_url, + }) + # Send initial progress count_passing_tests = _get_count_passing_tests() passing, in_progress, total = count_passing_tests(project_dir) @@ -244,9 +281,13 @@ async def on_status_change(status: str): except asyncio.CancelledError: pass - # Unregister callbacks + # Unregister agent callbacks agent_manager.remove_output_callback(on_output) agent_manager.remove_status_callback(on_status_change) + # Unregister dev server callbacks + devserver_manager.remove_output_callback(on_dev_output) + devserver_manager.remove_status_callback(on_dev_status_change) + # Disconnect from manager await manager.disconnect(websocket, project_name) diff --git a/ui/package-lock.json b/ui/package-lock.json index e3df4462..6135f476 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,17 +1,20 @@ { - "name": "autonomous-coding-ui", + "name": "autocoder", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "autonomous-coding-ui", + "name": "autocoder", "version": "1.0.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.60.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "lucide-react": "^0.460.0", @@ -2628,6 +2631,27 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", diff --git a/ui/package.json b/ui/package.json index 5b36ec72..560f821a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,9 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.60.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "lucide-react": "^0.460.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 328a31b7..4a33b9e7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,12 +13,13 @@ import { ProgressDashboard } from './components/ProgressDashboard' import { SetupWizard } from './components/SetupWizard' import { AddFeatureForm } from './components/AddFeatureForm' import { FeatureModal } from './components/FeatureModal' -import { DebugLogViewer } from './components/DebugLogViewer' +import { DebugLogViewer, type TabType } from './components/DebugLogViewer' import { AgentThought } from './components/AgentThought' import { AssistantFAB } from './components/AssistantFAB' import { AssistantPanel } from './components/AssistantPanel' import { ExpandProjectModal } from './components/ExpandProjectModal' import { SettingsModal } from './components/SettingsModal' +import { DevServerControl } from './components/DevServerControl' import { Loader2, Settings } from 'lucide-react' import type { Feature } from './lib/types' @@ -37,6 +38,7 @@ function App() { const [setupComplete, setSetupComplete] = useState(true) // Start optimistic const [debugOpen, setDebugOpen] = useState(false) const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height + const [debugActiveTab, setDebugActiveTab] = useState('agent') const [assistantOpen, setAssistantOpen] = useState(false) const [showSettings, setShowSettings] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false) @@ -88,6 +90,22 @@ function App() { setDebugOpen(prev => !prev) } + // T : Toggle terminal tab in debug panel + if (e.key === 't' || e.key === 'T') { + e.preventDefault() + if (!debugOpen) { + // If panel is closed, open it and switch to terminal tab + setDebugOpen(true) + setDebugActiveTab('terminal') + } else if (debugActiveTab === 'terminal') { + // If already on terminal tab, close the panel + setDebugOpen(false) + } else { + // If open but on different tab, switch to terminal + setDebugActiveTab('terminal') + } + } + // N : Add new feature (when project selected) if ((e.key === 'n' || e.key === 'N') && selectedProject) { e.preventDefault() @@ -133,7 +151,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings, isSpecCreating]) + }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -178,6 +196,12 @@ function App() { status={wsState.agentStatus} /> + + + + {/* Tabs - only visible when open */} + {isOpen && ( +
+ + + +
+ )} + + {/* Log count and status - only for log tabs */} + {isOpen && activeTab !== 'terminal' && ( + <> + {getCurrentLogCount() > 0 && ( + + {getCurrentLogCount()} + + )} + {isAutoScrollPaused() && ( + + Paused + + )} + )}
- {isOpen && ( + {/* Clear button - only for log tabs */} + {isOpen && activeTab !== 'terminal' && (
- {/* Log content area */} + {/* Content area */} {isOpen && ( -
- {logs.length === 0 ? ( -
- No logs yet. Start the agent to see output. +
+ {/* Agent Logs Tab */} + {activeTab === 'agent' && ( +
+ {logs.length === 0 ? ( +
+ No logs yet. Start the agent to see output. +
+ ) : ( +
+ {logs.map((log, index) => { + const level = getLogLevel(log.line) + const colorClass = getLogColor(level) + const timestamp = formatTimestamp(log.timestamp) + + return ( +
+ + {timestamp} + + + {log.line} + +
+ ) + })} +
+ )}
- ) : ( -
- {logs.map((log, index) => { - const level = getLogLevel(log.line) - const colorClass = getLogColor(level) - const timestamp = formatTimestamp(log.timestamp) - - return ( -
- - {timestamp} - - - {log.line} - -
- ) - })} + )} + + {/* Dev Server Logs Tab */} + {activeTab === 'devserver' && ( +
+ {devLogs.length === 0 ? ( +
+ No dev server logs yet. +
+ ) : ( +
+ {devLogs.map((log, index) => { + const level = getLogLevel(log.line) + const colorClass = getLogColor(level) + const timestamp = formatTimestamp(log.timestamp) + + return ( +
+ + {timestamp} + + + {log.line} + +
+ ) + })} +
+ )}
)} + + {/* Terminal Tab */} + {activeTab === 'terminal' && ( + + )}
)}
) } + +// Export the TabType for use in parent components +export type { TabType } diff --git a/ui/src/components/DevServerControl.tsx b/ui/src/components/DevServerControl.tsx new file mode 100644 index 00000000..79735a24 --- /dev/null +++ b/ui/src/components/DevServerControl.tsx @@ -0,0 +1,155 @@ +import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { DevServerStatus } from '../lib/types' +import { startDevServer, stopDevServer } from '../lib/api' + +// Re-export DevServerStatus from lib/types for consumers that import from here +export type { DevServerStatus } + +// ============================================================================ +// React Query Hooks (Internal) +// ============================================================================ + +/** + * Internal hook to start the dev server for a project. + * Invalidates the dev-server-status query on success. + */ +function useStartDevServer(projectName: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => startDevServer(projectName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) + }, + }) +} + +/** + * Internal hook to stop the dev server for a project. + * Invalidates the dev-server-status query on success. + */ +function useStopDevServer(projectName: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => stopDevServer(projectName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) + }, + }) +} + +// ============================================================================ +// Component +// ============================================================================ + +interface DevServerControlProps { + projectName: string + status: DevServerStatus + url: string | null +} + +/** + * DevServerControl provides start/stop controls for a project's development server. + * + * Features: + * - Toggle button to start/stop the dev server + * - Shows loading state during operations + * - Displays clickable URL when server is running + * - Uses neobrutalism design with cyan accent when running + */ +export function DevServerControl({ projectName, status, url }: DevServerControlProps) { + const startDevServerMutation = useStartDevServer(projectName) + const stopDevServerMutation = useStopDevServer(projectName) + + const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending + + const handleStart = () => { + // Clear any previous errors before starting + stopDevServerMutation.reset() + startDevServerMutation.mutate() + } + const handleStop = () => { + // Clear any previous errors before stopping + startDevServerMutation.reset() + stopDevServerMutation.mutate() + } + + // Server is stopped when status is 'stopped' or 'crashed' (can restart) + const isStopped = status === 'stopped' || status === 'crashed' + // Server is in a running state + const isRunning = status === 'running' + // Server has crashed + const isCrashed = status === 'crashed' + + return ( +
+ {isStopped ? ( + + ) : ( + + )} + + {/* Show URL as clickable link when server is running */} + {isRunning && url && ( + + {url} + + + )} + + {/* Error display */} + {(startDevServerMutation.error || stopDevServerMutation.error) && ( + + {String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')} + + )} +
+ ) +} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx new file mode 100644 index 00000000..0d8ec28d --- /dev/null +++ b/ui/src/components/Terminal.tsx @@ -0,0 +1,512 @@ +/** + * Interactive Terminal Component + * + * Full terminal emulation using xterm.js with WebSocket connection to the backend. + * Supports input/output streaming, terminal resizing, and reconnection handling. + */ + +import { useEffect, useRef, useCallback, useState } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' + +interface TerminalProps { + projectName: string + isActive: boolean +} + +// WebSocket message types for terminal I/O +interface TerminalInputMessage { + type: 'input' + data: string // base64 encoded +} + +interface TerminalResizeMessage { + type: 'resize' + cols: number + rows: number +} + +interface TerminalOutputMessage { + type: 'output' + data: string // base64 encoded +} + +interface TerminalExitMessage { + type: 'exit' + code: number +} + +type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage + +// Neobrutalism theme colors for xterm +const TERMINAL_THEME = { + background: '#1a1a1a', + foreground: '#ffffff', + cursor: '#ff006e', // --color-neo-accent + cursorAccent: '#1a1a1a', + selectionBackground: 'rgba(255, 0, 110, 0.3)', + selectionForeground: '#ffffff', + black: '#1a1a1a', + red: '#ff5400', + green: '#70e000', + yellow: '#ffd60a', + blue: '#00b4d8', + magenta: '#ff006e', + cyan: '#00b4d8', + white: '#ffffff', + brightBlack: '#4a4a4a', + brightRed: '#ff7733', + brightGreen: '#8fff00', + brightYellow: '#ffe44d', + brightBlue: '#33c7e6', + brightMagenta: '#ff4d94', + brightCyan: '#33c7e6', + brightWhite: '#ffffff', +} + +// Reconnection configuration +const RECONNECT_DELAY_BASE = 1000 +const RECONNECT_DELAY_MAX = 30000 + +export function Terminal({ projectName, isActive }: TerminalProps) { + const containerRef = useRef(null) + const terminalRef = useRef(null) + const fitAddonRef = useRef(null) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const reconnectAttempts = useRef(0) + const isInitializedRef = useRef(false) + const isConnectingRef = useRef(false) + const hasExitedRef = useRef(false) + // Track intentional disconnection to prevent auto-reconnect race condition + const isManualCloseRef = useRef(false) + // Store connect function in ref to avoid useEffect dependency issues + const connectRef = useRef<(() => void) | null>(null) + // Track last project to avoid duplicate connect on initial activation + const lastProjectRef = useRef(null) + + const [isConnected, setIsConnected] = useState(false) + const [hasExited, setHasExited] = useState(false) + const [exitCode, setExitCode] = useState(null) + + // Keep ref in sync with state for use in callbacks without re-creating them + useEffect(() => { + hasExitedRef.current = hasExited + }, [hasExited]) + + /** + * Encode string to base64 + */ + const encodeBase64 = useCallback((str: string): string => { + // Handle Unicode by encoding to UTF-8 first + const encoder = new TextEncoder() + const bytes = encoder.encode(str) + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) + }, []) + + /** + * Decode base64 to string + */ + const decodeBase64 = useCallback((base64: string): string => { + try { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + const decoder = new TextDecoder() + return decoder.decode(bytes) + } catch { + console.error('Failed to decode base64 data') + return '' + } + }, []) + + /** + * Send a message through the WebSocket + */ + const sendMessage = useCallback( + (message: TerminalInputMessage | TerminalResizeMessage) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)) + } + }, + [] + ) + + /** + * Send resize message to server + */ + const sendResize = useCallback( + (cols: number, rows: number) => { + const message: TerminalResizeMessage = { + type: 'resize', + cols, + rows, + } + sendMessage(message) + }, + [sendMessage] + ) + + /** + * Fit terminal to container and notify server of new dimensions + */ + const fitTerminal = useCallback(() => { + if (fitAddonRef.current && terminalRef.current) { + try { + fitAddonRef.current.fit() + const { cols, rows } = terminalRef.current + sendResize(cols, rows) + } catch { + // Container may not be visible yet, ignore + } + } + }, [sendResize]) + + /** + * Connect to the terminal WebSocket + */ + const connect = useCallback(() => { + if (!projectName || !isActive) return + + // Prevent multiple simultaneous connection attempts + if ( + isConnectingRef.current || + wsRef.current?.readyState === WebSocket.CONNECTING || + wsRef.current?.readyState === WebSocket.OPEN + ) { + return + } + + isConnectingRef.current = true + + // Clear any pending reconnection + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Build WebSocket URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + isConnectingRef.current = false + setIsConnected(true) + setHasExited(false) + setExitCode(null) + reconnectAttempts.current = 0 + + // Send initial size after connection + if (terminalRef.current) { + const { cols, rows } = terminalRef.current + sendResize(cols, rows) + } + } + + ws.onmessage = (event) => { + try { + const message: TerminalServerMessage = JSON.parse(event.data) + + switch (message.type) { + case 'output': { + const decoded = decodeBase64(message.data) + if (decoded && terminalRef.current) { + terminalRef.current.write(decoded) + } + break + } + case 'exit': { + setHasExited(true) + setExitCode(message.code) + if (terminalRef.current) { + terminalRef.current.writeln('') + terminalRef.current.writeln( + `\x1b[33m[Shell exited with code ${message.code}]\x1b[0m` + ) + terminalRef.current.writeln( + '\x1b[90mPress any key to reconnect...\x1b[0m' + ) + } + break + } + } + } catch { + console.error('Failed to parse terminal WebSocket message') + } + } + + ws.onclose = () => { + isConnectingRef.current = false + setIsConnected(false) + wsRef.current = null + + // Only reconnect if still active, not intentionally exited, and not manually closed + // Use refs to avoid re-creating this callback when state changes + const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current + // Reset manual close flag after checking (so subsequent disconnects can auto-reconnect) + isManualCloseRef.current = false + + if (shouldReconnect) { + // Exponential backoff reconnection + const delay = Math.min( + RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), + RECONNECT_DELAY_MAX + ) + reconnectAttempts.current++ + + reconnectTimeoutRef.current = window.setTimeout(() => { + connect() + }, delay) + } + } + + ws.onerror = () => { + // Will trigger onclose, which handles reconnection + ws.close() + } + } catch { + isConnectingRef.current = false + // Failed to connect, attempt reconnection + const delay = Math.min( + RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), + RECONNECT_DELAY_MAX + ) + reconnectAttempts.current++ + + reconnectTimeoutRef.current = window.setTimeout(() => { + connect() + }, delay) + } + }, [projectName, isActive, sendResize, decodeBase64]) + + // Keep connect ref up to date + useEffect(() => { + connectRef.current = connect + }, [connect]) + + /** + * Initialize xterm.js terminal + */ + const initializeTerminal = useCallback(() => { + if (!containerRef.current || isInitializedRef.current) return + + // Create terminal instance + const terminal = new XTerm({ + theme: TERMINAL_THEME, + fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', + fontSize: 14, + cursorBlink: true, + cursorStyle: 'block', + allowProposedApi: true, + scrollback: 10000, + }) + + // Create and load FitAddon + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + + // Open terminal in container + terminal.open(containerRef.current) + + // Store references + terminalRef.current = terminal + fitAddonRef.current = fitAddon + isInitializedRef.current = true + + // Initial fit + setTimeout(() => { + fitTerminal() + }, 0) + + // Handle keyboard input + terminal.onData((data) => { + // If shell has exited, reconnect on any key + // Use ref to avoid re-creating this callback when hasExited changes + if (hasExitedRef.current) { + setHasExited(false) + setExitCode(null) + connectRef.current?.() + return + } + + // Send input to server + const message: TerminalInputMessage = { + type: 'input', + data: encodeBase64(data), + } + sendMessage(message) + }) + + // Handle terminal resize + terminal.onResize(({ cols, rows }) => { + sendResize(cols, rows) + }) + }, [fitTerminal, encodeBase64, sendMessage, sendResize]) + + /** + * Handle window resize + */ + useEffect(() => { + if (!isActive) return + + const handleResize = () => { + fitTerminal() + } + + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [isActive, fitTerminal]) + + /** + * Initialize terminal and WebSocket when becoming active + */ + useEffect(() => { + if (!isActive) { + // Clean up when becoming inactive + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + return + } + + // Initialize terminal if not already done + if (!isInitializedRef.current) { + initializeTerminal() + } else { + // Re-fit when becoming active again + setTimeout(() => { + fitTerminal() + }, 0) + } + + // Connect WebSocket using ref to avoid dependency on connect callback + connectRef.current?.() + }, [isActive, initializeTerminal, fitTerminal]) + + /** + * Fit terminal when isActive becomes true + */ + useEffect(() => { + if (isActive && terminalRef.current) { + // Small delay to ensure container is visible + const timeoutId = setTimeout(() => { + fitTerminal() + terminalRef.current?.focus() + }, 100) + return () => clearTimeout(timeoutId) + } + }, [isActive, fitTerminal]) + + /** + * Cleanup on unmount + */ + useEffect(() => { + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + if (terminalRef.current) { + terminalRef.current.dispose() + } + isInitializedRef.current = false + } + }, []) + + /** + * Reconnect when project changes + */ + useEffect(() => { + if (isActive && isInitializedRef.current) { + // Only reconnect if project actually changed, not on initial activation + // This prevents duplicate connect calls when both isActive and projectName effects run + if (lastProjectRef.current === null) { + // Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect) + lastProjectRef.current = projectName + return + } + + if (lastProjectRef.current === projectName) { + // Project didn't change, skip + return + } + + // Project changed - update tracking + lastProjectRef.current = projectName + + // Clear terminal and reset cursor position + if (terminalRef.current) { + terminalRef.current.clear() + terminalRef.current.write('\x1b[H') // Move cursor to home position + } + + // Set manual close flag to prevent auto-reconnect race condition + isManualCloseRef.current = true + + // Close existing connection and reset connecting state + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + isConnectingRef.current = false + + // Reset state + setHasExited(false) + setExitCode(null) + reconnectAttempts.current = 0 + + // Connect to new project using ref to avoid dependency on connect callback + connectRef.current?.() + } + }, [projectName, isActive]) + + return ( +
+ {/* Connection status indicator */} +
+
+ {!isConnected && !hasExited && ( + Connecting... + )} + {hasExited && exitCode !== null && ( + + Exit: {exitCode} + + )} +
+ + {/* Terminal container */} +
+
+ ) +} diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 0e390daa..2f7e385d 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -3,7 +3,7 @@ */ import { useEffect, useRef, useState, useCallback } from 'react' -import type { WSMessage, AgentStatus } from '../lib/types' +import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types' interface WebSocketState { progress: { @@ -15,6 +15,9 @@ interface WebSocketState { agentStatus: AgentStatus logs: Array<{ line: string; timestamp: string }> isConnected: boolean + devServerStatus: DevServerStatus + devServerUrl: string | null + devLogs: Array<{ line: string; timestamp: string }> } const MAX_LOGS = 100 // Keep last 100 log lines @@ -25,6 +28,9 @@ export function useProjectWebSocket(projectName: string | null) { agentStatus: 'stopped', logs: [], isConnected: false, + devServerStatus: 'stopped', + devServerUrl: null, + devLogs: [], }) const wsRef = useRef(null) @@ -86,6 +92,24 @@ export function useProjectWebSocket(projectName: string | null) { // Feature updates will trigger a refetch via React Query break + case 'dev_log': + setState(prev => ({ + ...prev, + devLogs: [ + ...prev.devLogs.slice(-MAX_LOGS + 1), + { line: message.line, timestamp: message.timestamp }, + ], + })) + break + + case 'dev_server_status': + setState(prev => ({ + ...prev, + devServerStatus: message.status, + devServerUrl: message.url, + })) + break + case 'pong': // Heartbeat response break @@ -131,6 +155,9 @@ export function useProjectWebSocket(projectName: string | null) { agentStatus: 'stopped', logs: [], isConnected: false, + devServerStatus: 'stopped', + devServerUrl: null, + devLogs: [], }) if (!projectName) { @@ -164,8 +191,14 @@ export function useProjectWebSocket(projectName: string | null) { setState(prev => ({ ...prev, logs: [] })) }, []) + // Clear dev logs function + const clearDevLogs = useCallback(() => { + setState(prev => ({ ...prev, devLogs: [] })) + }, []) + return { ...state, clearLogs, + clearDevLogs, } } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index dea0979e..1fdf1469 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -21,6 +21,8 @@ import type { Settings, SettingsUpdate, ModelsResponse, + DevServerStatusResponse, + DevServerConfig, } from './types' const API_BASE = '/api' @@ -301,3 +303,33 @@ export async function updateSettings(settings: SettingsUpdate): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/status`) +} + +export async function startDevServer( + projectName: string, + command?: string +): Promise<{ success: boolean; message: string }> { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/start`, { + method: 'POST', + body: JSON.stringify({ command }), + }) +} + +export async function stopDevServer( + projectName: string +): Promise<{ success: boolean; message: string }> { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/stop`, { + method: 'POST', + }) +} + +export async function getDevServerConfig(projectName: string): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 663733a4..df303dbf 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -107,8 +107,26 @@ export interface SetupStatus { npm: boolean } +// Dev Server types +export type DevServerStatus = 'stopped' | 'running' | 'crashed' + +export interface DevServerStatusResponse { + status: DevServerStatus + pid: number | null + url: string | null + command: string | null + started_at: string | null +} + +export interface DevServerConfig { + detected_type: string | null + detected_command: string | null + custom_command: string | null + effective_command: string | null +} + // WebSocket message types -export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' +export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' export interface WSProgressMessage { type: 'progress' @@ -139,12 +157,26 @@ export interface WSPongMessage { type: 'pong' } +export interface WSDevLogMessage { + type: 'dev_log' + line: string + timestamp: string +} + +export interface WSDevServerStatusMessage { + type: 'dev_server_status' + status: DevServerStatus + url: string | null +} + export type WSMessage = | WSProgressMessage | WSFeatureUpdateMessage | WSLogMessage | WSAgentStatusMessage | WSPongMessage + | WSDevLogMessage + | WSDevServerStatusMessage // ============================================================================ // Spec Chat Types diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index b2e71fb3..fea90a26 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file From a7f8c3aa8d1dd6e0b2dc462298294a284dec37c3 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 11:55:50 +0200 Subject: [PATCH 026/265] feat: add multiple terminal tabs with rename capability Add support for multiple terminal instances per project with tabbed navigation in the debug panel. Each terminal maintains its own PTY session and WebSocket connection. Backend changes: - Add terminal metadata storage (id, name, created_at) per project - Update terminal_manager.py with create, list, rename, delete functions - Extend WebSocket endpoint to /api/terminal/ws/{project}/{terminal_id} - Add REST endpoints for terminal CRUD operations - Implement deferred PTY start with initial resize message Frontend changes: - Create TerminalTabs component with neobrutalism styling - Support double-click rename and right-click context menu - Fix terminal switching issues with transform-based hiding - Use isActiveRef to prevent stale closure bugs in connect() - Add double requestAnimationFrame for reliable activation timing - Implement proper dimension validation in fitTerminal() Other updates: - Add GLM model configuration documentation to README - Simplify client.py by removing CLI_COMMAND support - Update chat session services with consistent patterns Co-Authored-By: Claude Opus 4.5 --- .env.example | 7 - README.md | 17 ++ client.py | 22 +- server/main.py | 16 +- server/routers/terminal.py | 262 +++++++++++++++++++--- server/services/assistant_chat_session.py | 16 +- server/services/expand_chat_session.py | 19 +- server/services/spec_chat_session.py | 17 +- server/services/terminal_manager.py | 211 ++++++++++++++++- start.py | 29 +-- ui/src/components/DebugLogViewer.tsx | 152 ++++++++++++- ui/src/components/Terminal.tsx | 164 ++++++++++---- ui/src/components/TerminalTabs.tsx | 246 ++++++++++++++++++++ ui/src/lib/api.ts | 39 ++++ ui/src/lib/types.ts | 7 + ui/tsconfig.tsbuildinfo | 2 +- 16 files changed, 1032 insertions(+), 194 deletions(-) create mode 100644 ui/src/components/TerminalTabs.tsx diff --git a/.env.example b/.env.example index 157af452..c4261004 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,6 @@ # Optional: N8N webhook for progress notifications # PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/... -# CLI Command Selection -# Choose which CLI command to use for the agent. -# - claude: Uses Anthropic's official Claude Code CLI (default) -# - glm: Uses GLM CLI (or any other compatible CLI) -# Defaults to 'claude' if not specified -# CLI_COMMAND=claude - # Playwright Browser Mode # Controls whether Playwright runs Chrome in headless mode (no visible browser window). # - true: Browser runs in background, invisible (recommended for using PC while agent works) diff --git a/README.md b/README.md index a5f62316..9af81726 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,23 @@ When test progress increases, the agent sends: } ``` +### Using GLM Models (Alternative to Claude) + +To use Zhipu AI's GLM models instead of Claude, create a settings file at `~/.claude/settings.json`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "ANTHROPIC_AUTH_TOKEN": "your-zhipu-api-key" + } +} +``` + +This routes Claude Code requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models while keeping all Claude Code features (MCP servers, hooks, permissions). + +Get an API key at: https://z.ai/subscribe + --- ## Customization diff --git a/client.py b/client.py index c0582767..f232a0a6 100644 --- a/client.py +++ b/client.py @@ -20,26 +20,12 @@ # Load environment variables from .env file if present load_dotenv() -# Default CLI command - can be overridden via CLI_COMMAND environment variable -# Common values: "claude" (default), "glm" -DEFAULT_CLI_COMMAND = "claude" - # Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var # When True, browser runs invisibly in background # When False, browser window is visible (default - useful for monitoring agent progress) DEFAULT_PLAYWRIGHT_HEADLESS = False -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", DEFAULT_CLI_COMMAND) - - def get_playwright_headless() -> bool: """ Get the Playwright headless mode setting. @@ -187,14 +173,12 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): print(" - Project settings enabled (skills, commands, CLAUDE.md)") print() - # Use system CLI instead of bundled one (avoids Bun runtime crash on Windows) - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI instead of bundled one (avoids Bun runtime crash on Windows) + system_cli = shutil.which("claude") if system_cli: print(f" - Using system CLI: {system_cli}") else: - print(f" - Warning: System CLI '{cli_command}' not found, using bundled CLI") + print(" - Warning: System 'claude' CLI not found, using bundled CLI") # Build MCP servers config - features is always included, playwright only in standard mode mcp_servers = { diff --git a/server/main.py b/server/main.py index 1c408682..8be2a50a 100644 --- a/server/main.py +++ b/server/main.py @@ -6,7 +6,6 @@ Provides REST API, WebSocket, and static file serving. """ -import os import shutil from contextlib import asynccontextmanager from pathlib import Path @@ -16,16 +15,6 @@ # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - from fastapi import FastAPI, HTTPException, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -152,9 +141,8 @@ async def health_check(): @app.get("/api/setup/status", response_model=SetupStatus) async def setup_status(): """Check system setup status.""" - # Check for CLI (configurable via CLI_COMMAND environment variable) - cli_command = get_cli_command() - claude_cli = shutil.which(cli_command) is not None + # Check for Claude CLI + claude_cli = shutil.which("claude") is not None # Check for CLI configuration directory # Note: CLI no longer stores credentials in ~/.claude/.credentials.json diff --git a/server/routers/terminal.py b/server/routers/terminal.py index 196e69f2..2183369e 100644 --- a/server/routers/terminal.py +++ b/server/routers/terminal.py @@ -2,8 +2,9 @@ Terminal Router =============== -WebSocket endpoint for interactive terminal I/O with PTY support. +REST and WebSocket endpoints for interactive terminal I/O with PTY support. Provides real-time bidirectional communication with terminal sessions. +Supports multiple terminals per project with create, list, rename, delete operations. """ import asyncio @@ -14,9 +15,18 @@ import sys from pathlib import Path -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel -from ..services.terminal_manager import get_terminal_session +from ..services.terminal_manager import ( + create_terminal, + delete_terminal, + get_terminal_info, + get_terminal_session, + list_terminals, + rename_terminal, + stop_terminal_session, +) # Add project root to path for registry import _root = Path(__file__).parent.parent.parent @@ -59,8 +69,170 @@ def validate_project_name(name: str) -> bool: 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: +def validate_terminal_id(terminal_id: str) -> bool: + """ + Validate terminal ID format. + + Args: + terminal_id: The terminal ID to validate + + Returns: + True if valid, False otherwise + """ + return bool(re.match(r"^[a-zA-Z0-9]{1,16}$", terminal_id)) + + +# Pydantic models for request/response bodies +class CreateTerminalRequest(BaseModel): + """Request body for creating a terminal.""" + + name: str | None = None + + +class RenameTerminalRequest(BaseModel): + """Request body for renaming a terminal.""" + + name: str + + +class TerminalInfoResponse(BaseModel): + """Response model for terminal info.""" + + id: str + name: str + created_at: str + + +# REST Endpoints + + +@router.get("/{project_name}") +async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse]: + """ + List all terminals for a project. + + Args: + project_name: Name of the project + + Returns: + List of terminal info objects + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + terminals = list_terminals(project_name) + + # If no terminals exist, create a default one + if not terminals: + info = create_terminal(project_name) + terminals = [info] + + return [ + TerminalInfoResponse(id=t.id, name=t.name, created_at=t.created_at) for t in terminals + ] + + +@router.post("/{project_name}") +async def create_project_terminal( + project_name: str, request: CreateTerminalRequest +) -> TerminalInfoResponse: + """ + Create a new terminal for a project. + + Args: + project_name: Name of the project + request: Request body with optional terminal name + + Returns: + The created terminal info + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + info = create_terminal(project_name, request.name) + return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at) + + +@router.patch("/{project_name}/{terminal_id}") +async def rename_project_terminal( + project_name: str, terminal_id: str, request: RenameTerminalRequest +) -> TerminalInfoResponse: + """ + Rename a terminal. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to rename + request: Request body with new name + + Returns: + The updated terminal info + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + if not validate_terminal_id(terminal_id): + raise HTTPException(status_code=400, detail="Invalid terminal ID") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + if not rename_terminal(project_name, terminal_id, request.name): + raise HTTPException(status_code=404, detail="Terminal not found") + + info = get_terminal_info(project_name, terminal_id) + if not info: + raise HTTPException(status_code=404, detail="Terminal not found") + + return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at) + + +@router.delete("/{project_name}/{terminal_id}") +async def delete_project_terminal(project_name: str, terminal_id: str) -> dict: + """ + Delete a terminal and stop its session. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to delete + + Returns: + Success message + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + if not validate_terminal_id(terminal_id): + raise HTTPException(status_code=400, detail="Invalid terminal ID") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + # Stop the session if it's running + await stop_terminal_session(project_name, terminal_id) + + # Delete the terminal metadata + if not delete_terminal(project_name, terminal_id): + raise HTTPException(status_code=404, detail="Terminal not found") + + return {"message": "Terminal deleted"} + + +# WebSocket Endpoint + + +@router.websocket("/ws/{project_name}/{terminal_id}") +async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_id: str) -> None: """ WebSocket endpoint for interactive terminal I/O. @@ -84,6 +256,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: ) return + # Validate terminal ID + if not validate_terminal_id(terminal_id): + await websocket.close( + code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID" + ) + return + # Look up project directory from registry project_dir = _get_project_path(project_name) if not project_dir: @@ -100,10 +279,19 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: ) return + # Verify terminal exists in metadata + terminal_info = get_terminal_info(project_name, terminal_id) + if not terminal_info: + await websocket.close( + code=TerminalCloseCode.PROJECT_NOT_FOUND, + reason="Terminal not found", + ) + return + await websocket.accept() - # Get or create terminal session for this project - session = get_terminal_session(project_name, project_dir) + # Get or create terminal session for this project/terminal + session = get_terminal_session(project_name, project_dir, terminal_id) # Queue for output data to send to client output_queue: asyncio.Queue[bytes] = asyncio.Queue() @@ -119,21 +307,9 @@ def on_output(data: bytes) -> None: # 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 + # Track if we need to wait for initial resize before starting + # This ensures the PTY is created with correct dimensions from the start + needs_initial_resize = not session.is_active # Task to send queued output to WebSocket async def send_output_task() -> None: @@ -159,6 +335,11 @@ async def send_output_task() -> None: async def monitor_exit_task() -> None: """Monitor the terminal session and notify client on exit.""" try: + # Wait for session to become active first (deferred start) + while not session.is_active: + await asyncio.sleep(0.1) + + # Now monitor until it becomes inactive while session.is_active: await asyncio.sleep(0.5) @@ -189,6 +370,13 @@ async def monitor_exit_task() -> None: await websocket.send_json({"type": "pong"}) elif msg_type == "input": + # Only allow input after terminal is started + if not session.is_active: + await websocket.send_json( + {"type": "error", "message": "Terminal not ready - send resize first"} + ) + continue + # Decode base64 input and write to PTY encoded_data = message.get("data", "") # Add size limit to prevent DoS @@ -222,7 +410,27 @@ async def monitor_exit_task() -> None: if isinstance(cols, int) and isinstance(rows, int): cols = max(10, min(500, cols)) rows = max(5, min(200, rows)) - session.resize(cols, rows) + + # If this is the first resize and session not started, start with these dimensions + # This ensures the PTY is created with correct size from the beginning + if needs_initial_resize and not session.is_active: + started = await session.start(cols=cols, rows=rows) + 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 + # Mark that we no longer need initial resize + needs_initial_resize = False + else: + session.resize(cols, rows) else: await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"}) @@ -233,10 +441,10 @@ async def monitor_exit_task() -> None: await websocket.send_json({"type": "error", "message": "Invalid JSON"}) except WebSocketDisconnect: - logger.info(f"Terminal WebSocket disconnected for {project_name}") + logger.info(f"Terminal WebSocket disconnected for {project_name}/{terminal_id}") except Exception as e: - logger.exception(f"Terminal WebSocket error for {project_name}") + logger.exception(f"Terminal WebSocket error for {project_name}/{terminal_id}") try: await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"}) except Exception: @@ -266,8 +474,8 @@ async def monitor_exit_task() -> None: if remaining_callbacks == 0: await session.stop() - logger.info(f"Terminal session stopped for {project_name} (last client disconnected)") + logger.info(f"Terminal session stopped for {project_name}/{terminal_id} (last client disconnected)") else: logger.info( - f"Client disconnected from {project_name}, {remaining_callbacks} clients remaining" + f"Client disconnected from {project_name}/{terminal_id}, {remaining_callbacks} clients remaining" ) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index bebed941..9e067f17 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -28,17 +28,6 @@ # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - - logger = logging.getLogger(__name__) # Root directory of the project @@ -242,9 +231,8 @@ async def start(self) -> AsyncGenerator[dict, None]: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) - # Use system CLI (configurable via CLI_COMMAND environment variable) - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI + system_cli = shutil.which("claude") try: self.client = ClaudeSDKClient( diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 659c7766..b1878047 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -9,7 +9,6 @@ import asyncio import json import logging -import os import re import shutil import threading @@ -26,16 +25,6 @@ # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - logger = logging.getLogger(__name__) @@ -135,14 +124,12 @@ async def start(self) -> AsyncGenerator[dict, None]: except UnicodeDecodeError: skill_content = skill_path.read_text(encoding="utf-8", errors="replace") - # Find and validate CLI before creating temp files - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Find and validate Claude CLI before creating temp files + system_cli = shutil.which("claude") if not system_cli: yield { "type": "error", - "content": f"CLI '{cli_command}' not found. Please install it or check your CLI_COMMAND setting." + "content": "Claude CLI not found. Please install it: npm install -g @anthropic-ai/claude-code" } return diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 7cb2beb7..b3b4e1cc 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -8,7 +8,6 @@ import json import logging -import os import shutil import threading from datetime import datetime @@ -23,16 +22,6 @@ # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - logger = logging.getLogger(__name__) @@ -156,10 +145,8 @@ async def start(self) -> AsyncGenerator[dict, None]: # Create Claude SDK client with limited tools for spec creation # Use Opus for best quality spec generation - # Use system CLI to avoid bundled Bun runtime crash (exit code 3) on Windows - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows + system_cli = shutil.which("claude") try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( diff --git a/server/services/terminal_manager.py b/server/services/terminal_manager.py index f9264ffb..09abfa2b 100644 --- a/server/services/terminal_manager.py +++ b/server/services/terminal_manager.py @@ -12,11 +12,24 @@ import platform import shutil import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Callable, Set logger = logging.getLogger(__name__) + +@dataclass +class TerminalInfo: + """Metadata for a terminal instance.""" + + id: str + name: str + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + + # Platform detection IS_WINDOWS = platform.system() == "Windows" @@ -506,39 +519,214 @@ async def _stop_unix(self) -> None: # Global registry of terminal sessions per project with thread safety -_sessions: dict[str, TerminalSession] = {} +# Structure: Dict[project_name, Dict[terminal_id, TerminalSession]] +_sessions: dict[str, dict[str, TerminalSession]] = {} _sessions_lock = threading.Lock() +# Terminal metadata registry (in-memory, resets on server restart) +# Structure: Dict[project_name, List[TerminalInfo]] +_terminal_metadata: dict[str, list[TerminalInfo]] = {} +_metadata_lock = threading.Lock() + + +def create_terminal(project_name: str, name: str | None = None) -> TerminalInfo: + """ + Create a new terminal entry for a project. + + Args: + project_name: Name of the project + name: Optional terminal name (auto-generated if not provided) + + Returns: + TerminalInfo for the new terminal + """ + with _metadata_lock: + if project_name not in _terminal_metadata: + _terminal_metadata[project_name] = [] + + terminals = _terminal_metadata[project_name] + + # Auto-generate name if not provided + if name is None: + existing_nums = [] + for t in terminals: + if t.name.startswith("Terminal "): + try: + num = int(t.name.replace("Terminal ", "")) + existing_nums.append(num) + except ValueError: + pass + next_num = max(existing_nums, default=0) + 1 + name = f"Terminal {next_num}" + + terminal_id = str(uuid.uuid4())[:8] + info = TerminalInfo(id=terminal_id, name=name) + terminals.append(info) + + logger.info(f"Created terminal '{name}' (ID: {terminal_id}) for project {project_name}") + return info + + +def list_terminals(project_name: str) -> list[TerminalInfo]: + """ + List all terminals for a project. + + Args: + project_name: Name of the project + + Returns: + List of TerminalInfo for the project + """ + with _metadata_lock: + return list(_terminal_metadata.get(project_name, [])) + + +def rename_terminal(project_name: str, terminal_id: str, new_name: str) -> bool: + """ + Rename a terminal. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to rename + new_name: New name for the terminal + + Returns: + True if renamed successfully, False if terminal not found + """ + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for terminal in terminals: + if terminal.id == terminal_id: + old_name = terminal.name + terminal.name = new_name + logger.info( + f"Renamed terminal '{old_name}' to '{new_name}' " + f"(ID: {terminal_id}) for project {project_name}" + ) + return True + return False + + +def delete_terminal(project_name: str, terminal_id: str) -> bool: + """ + Delete a terminal and stop its session if active. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to delete -def get_terminal_session(project_name: str, project_dir: Path) -> TerminalSession: + Returns: + True if deleted, False if not found + """ + # Remove from metadata + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for i, terminal in enumerate(terminals): + if terminal.id == terminal_id: + terminals.pop(i) + logger.info( + f"Deleted terminal '{terminal.name}' (ID: {terminal_id}) " + f"for project {project_name}" + ) + break + else: + return False + + # Remove session if exists (will be stopped async by caller) + with _sessions_lock: + project_sessions = _sessions.get(project_name, {}) + if terminal_id in project_sessions: + del project_sessions[terminal_id] + + return True + + +def get_terminal_session( + project_name: str, project_dir: Path, terminal_id: str | None = None +) -> TerminalSession: """ Get or create a terminal session for a project (thread-safe). Args: project_name: Name of the project project_dir: Absolute path to the project directory + terminal_id: ID of the terminal (creates default if not provided) Returns: - TerminalSession instance for the project + TerminalSession instance for the project/terminal """ + # Ensure terminal metadata exists + if terminal_id is None: + # Create default terminal if none exists + terminals = list_terminals(project_name) + if not terminals: + info = create_terminal(project_name) + terminal_id = info.id + else: + terminal_id = terminals[0].id + with _sessions_lock: if project_name not in _sessions: - _sessions[project_name] = TerminalSession(project_name, project_dir) - return _sessions[project_name] + _sessions[project_name] = {} + project_sessions = _sessions[project_name] + if terminal_id not in project_sessions: + project_sessions[terminal_id] = TerminalSession(project_name, project_dir) -def remove_terminal_session(project_name: str) -> TerminalSession | None: + return project_sessions[terminal_id] + + +def remove_terminal_session(project_name: str, terminal_id: str) -> TerminalSession | None: """ Remove a terminal session from the registry. Args: project_name: Name of the project + terminal_id: ID of the terminal Returns: The removed session, or None if not found """ with _sessions_lock: - return _sessions.pop(project_name, None) + project_sessions = _sessions.get(project_name, {}) + return project_sessions.pop(terminal_id, None) + + +def get_terminal_info(project_name: str, terminal_id: str) -> TerminalInfo | None: + """ + Get terminal info by ID. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal + + Returns: + TerminalInfo if found, None otherwise + """ + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for terminal in terminals: + if terminal.id == terminal_id: + return terminal + return None + + +async def stop_terminal_session(project_name: str, terminal_id: str) -> bool: + """ + Stop a specific terminal session. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal + + Returns: + True if stopped, False if not found + """ + session = remove_terminal_session(project_name, terminal_id) + if session and session.is_active: + await session.stop() + return True + return False async def cleanup_all_terminals() -> None: @@ -548,9 +736,11 @@ async def cleanup_all_terminals() -> None: Called on server shutdown to ensure all PTY processes are terminated. """ with _sessions_lock: - sessions = list(_sessions.values()) + all_sessions = [] + for project_sessions in _sessions.values(): + all_sessions.extend(project_sessions.values()) - for session in sessions: + for session in all_sessions: try: if session.is_active: await session.stop() @@ -560,4 +750,7 @@ async def cleanup_all_terminals() -> None: with _sessions_lock: _sessions.clear() + with _metadata_lock: + _terminal_metadata.clear() + logger.info("All terminal sessions cleaned up") diff --git a/start.py b/start.py index df979096..a230d13b 100644 --- a/start.py +++ b/start.py @@ -20,17 +20,6 @@ # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - - from prompts import ( get_project_prompts_dir, has_project_prompts, @@ -237,9 +226,8 @@ def run_spec_creation(project_dir: Path) -> bool: # Launch CLI with /create-spec command # Project path included in command string so it populates $ARGUMENTS # Capture stderr to detect auth errors while letting stdout flow to terminal - cli_command = get_cli_command() result = subprocess.run( - [cli_command, f"/create-spec {project_dir}"], + ["claude", f"/create-spec {project_dir}"], check=False, # Don't raise on non-zero exit cwd=str(Path(__file__).parent), # Run from project root stderr=subprocess.PIPE, @@ -267,17 +255,13 @@ def run_spec_creation(project_dir: Path) -> bool: print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}") # If failed with non-zero exit and no spec, might be auth issue if result.returncode != 0: - print(f"\nIf you're having authentication issues, try running: {cli_command} login") + print("\nIf you're having authentication issues, try running: claude login") return False except FileNotFoundError: - cli_command = get_cli_command() - print(f"\nError: '{cli_command}' command not found.") - if cli_command == "claude": - print("Make sure Claude Code CLI is installed:") - print(" npm install -g @anthropic-ai/claude-code") - else: - print(f"Make sure the '{cli_command}' CLI is installed and in your PATH.") + print("\nError: 'claude' command not found.") + print("Make sure Claude Code CLI is installed:") + print(" npm install -g @anthropic-ai/claude-code") return False except KeyboardInterrupt: print("\n\nSpec creation cancelled.") @@ -429,8 +413,7 @@ def run_agent(project_name: str, project_dir: Path) -> None: print(f"\nAgent error:\n{stderr_output.strip()}") # Still hint about auth if exit was unexpected if "error" in stderr_output.lower() or "exception" in stderr_output.lower(): - cli_command = get_cli_command() - print(f"\nIf this is an authentication issue, try running: {cli_command} login") + print("\nIf this is an authentication issue, try running: claude login") except KeyboardInterrupt: print("\n\nAgent interrupted. Run again to resume.") diff --git a/ui/src/components/DebugLogViewer.tsx b/ui/src/components/DebugLogViewer.tsx index 727fa4b9..40c07fc6 100644 --- a/ui/src/components/DebugLogViewer.tsx +++ b/ui/src/components/DebugLogViewer.tsx @@ -9,6 +9,9 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react' import { Terminal } from './Terminal' +import { TerminalTabs } from './TerminalTabs' +import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api' +import type { TerminalInfo } from '@/lib/types' const MIN_HEIGHT = 150 const MAX_HEIGHT = 600 @@ -61,6 +64,11 @@ export function DebugLogViewer({ return (saved as TabType) || 'agent' }) + // Terminal management state + const [terminals, setTerminals] = useState([]) + const [activeTerminalId, setActiveTerminalId] = useState(null) + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + // Use controlled tab if provided, otherwise use internal state const activeTab = controlledActiveTab ?? internalActiveTab const setActiveTab = (tab: TabType) => { @@ -69,6 +77,91 @@ export function DebugLogViewer({ onTabChange?.(tab) } + // Fetch terminals for the project + const fetchTerminals = useCallback(async () => { + if (!projectName) return + + setIsLoadingTerminals(true) + try { + const terminalList = await listTerminals(projectName) + setTerminals(terminalList) + + // Set active terminal to first one if not set or current one doesn't exist + if (terminalList.length > 0) { + if (!activeTerminalId || !terminalList.find((t) => t.id === activeTerminalId)) { + setActiveTerminalId(terminalList[0].id) + } + } + } catch (err) { + console.error('Failed to fetch terminals:', err) + } finally { + setIsLoadingTerminals(false) + } + }, [projectName, activeTerminalId]) + + // Handle creating a new terminal + const handleCreateTerminal = useCallback(async () => { + if (!projectName) return + + try { + const newTerminal = await createTerminal(projectName) + setTerminals((prev) => [...prev, newTerminal]) + setActiveTerminalId(newTerminal.id) + } catch (err) { + console.error('Failed to create terminal:', err) + } + }, [projectName]) + + // Handle renaming a terminal + const handleRenameTerminal = useCallback( + async (terminalId: string, newName: string) => { + if (!projectName) return + + try { + const updated = await renameTerminal(projectName, terminalId, newName) + setTerminals((prev) => + prev.map((t) => (t.id === terminalId ? updated : t)) + ) + } catch (err) { + console.error('Failed to rename terminal:', err) + } + }, + [projectName] + ) + + // Handle closing a terminal + const handleCloseTerminal = useCallback( + async (terminalId: string) => { + if (!projectName || terminals.length <= 1) return + + try { + await deleteTerminal(projectName, terminalId) + setTerminals((prev) => prev.filter((t) => t.id !== terminalId)) + + // If we closed the active terminal, switch to another one + if (activeTerminalId === terminalId) { + const remaining = terminals.filter((t) => t.id !== terminalId) + if (remaining.length > 0) { + setActiveTerminalId(remaining[0].id) + } + } + } catch (err) { + console.error('Failed to close terminal:', err) + } + }, + [projectName, terminals, activeTerminalId] + ) + + // Fetch terminals when project changes + useEffect(() => { + if (projectName) { + fetchTerminals() + } else { + setTerminals([]) + setActiveTerminalId(null) + } + }, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps + // Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up) useEffect(() => { if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') { @@ -429,10 +522,61 @@ export function DebugLogViewer({ {/* Terminal Tab */} {activeTab === 'terminal' && ( - +
+ {/* Terminal tabs bar */} + {terminals.length > 0 && ( + + )} + + {/* Terminal content - render all terminals and show/hide to preserve buffers */} +
+ {isLoadingTerminals ? ( +
+ Loading terminals... +
+ ) : terminals.length === 0 ? ( +
+ No terminal available +
+ ) : ( + /* Render all terminals stacked on top of each other. + * Active terminal is visible and receives input. + * Inactive terminals are moved off-screen with transform to: + * 1. Trigger IntersectionObserver (xterm.js pauses rendering) + * 2. Preserve terminal buffer content + * 3. Allow proper dimension calculation when becoming visible + * Using transform instead of opacity/display:none for best xterm.js compatibility. + */ + terminals.map((terminal) => { + const isActiveTerminal = terminal.id === activeTerminalId + return ( +
+ +
+ ) + }) + )} +
+
)}
)} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index 0d8ec28d..69b6fcbb 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -12,6 +12,7 @@ import '@xterm/xterm/css/xterm.css' interface TerminalProps { projectName: string + terminalId: string isActive: boolean } @@ -69,7 +70,7 @@ const TERMINAL_THEME = { const RECONNECT_DELAY_BASE = 1000 const RECONNECT_DELAY_MAX = 30000 -export function Terminal({ projectName, isActive }: TerminalProps) { +export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const containerRef = useRef(null) const terminalRef = useRef(null) const fitAddonRef = useRef(null) @@ -83,8 +84,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) { const isManualCloseRef = useRef(false) // Store connect function in ref to avoid useEffect dependency issues const connectRef = useRef<(() => void) | null>(null) - // Track last project to avoid duplicate connect on initial activation + // Track last project/terminal to avoid duplicate connect on initial activation const lastProjectRef = useRef(null) + const lastTerminalIdRef = useRef(null) + // Track isActive in a ref to avoid stale closure issues in connect() + const isActiveRef = useRef(isActive) const [isConnected, setIsConnected] = useState(false) const [hasExited, setHasExited] = useState(false) @@ -95,6 +99,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) { hasExitedRef.current = hasExited }, [hasExited]) + // Keep isActiveRef in sync with isActive prop to avoid stale closures + useEffect(() => { + isActiveRef.current = isActive + }, [isActive]) + /** * Encode string to base64 */ @@ -160,9 +169,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) { const fitTerminal = useCallback(() => { if (fitAddonRef.current && terminalRef.current) { try { - fitAddonRef.current.fit() + // Try to get proposed dimensions first + const dimensions = fitAddonRef.current.proposeDimensions() + const hasValidDimensions = dimensions && + dimensions.cols && + dimensions.rows && + !isNaN(dimensions.cols) && + !isNaN(dimensions.rows) && + dimensions.cols >= 1 && + dimensions.rows >= 1 + + if (hasValidDimensions) { + // Valid dimensions - fit the terminal + fitAddonRef.current.fit() + } + + // Always send resize with current terminal dimensions + // This ensures the server has the correct size even if fit() was skipped const { cols, rows } = terminalRef.current - sendResize(cols, rows) + if (cols > 0 && rows > 0) { + sendResize(cols, rows) + } } catch { // Container may not be visible yet, ignore } @@ -173,7 +200,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) { * Connect to the terminal WebSocket */ const connect = useCallback(() => { - if (!projectName || !isActive) return + // Use isActiveRef.current instead of isActive to avoid stale closure issues + // when connect is called from setTimeout callbacks + if (!projectName || !terminalId || !isActiveRef.current) return // Prevent multiple simultaneous connection attempts if ( @@ -192,10 +221,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { reconnectTimeoutRef.current = null } - // Build WebSocket URL + // Build WebSocket URL with terminal ID const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}` + const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}` try { const ws = new WebSocket(wsUrl) @@ -253,8 +282,8 @@ export function Terminal({ projectName, isActive }: TerminalProps) { wsRef.current = null // Only reconnect if still active, not intentionally exited, and not manually closed - // Use refs to avoid re-creating this callback when state changes - const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current + // Use isActiveRef.current to get the current value, avoiding stale closure + const shouldReconnect = isActiveRef.current && !hasExitedRef.current && !isManualCloseRef.current // Reset manual close flag after checking (so subsequent disconnects can auto-reconnect) isManualCloseRef.current = false @@ -289,7 +318,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) { connect() }, delay) } - }, [projectName, isActive, sendResize, decodeBase64]) + }, [projectName, terminalId, sendResize, decodeBase64]) // Keep connect ref up to date useEffect(() => { @@ -325,10 +354,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) { fitAddonRef.current = fitAddon isInitializedRef.current = true - // Initial fit - setTimeout(() => { - fitTerminal() - }, 0) + // NOTE: Don't call fitTerminal() here - let the activation effect handle it + // after layout is fully calculated. This avoids dimension calculation issues + // when the container is first rendered. // Handle keyboard input terminal.onData((data) => { @@ -353,7 +381,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) { terminal.onResize(({ cols, rows }) => { sendResize(cols, rows) }) - }, [fitTerminal, encodeBase64, sendMessage, sendResize]) + }, [encodeBase64, sendMessage, sendResize]) /** * Handle window resize @@ -376,43 +404,83 @@ export function Terminal({ projectName, isActive }: TerminalProps) { */ useEffect(() => { if (!isActive) { - // Clean up when becoming inactive + // When becoming inactive, just clear reconnect timeout but keep WebSocket alive + // This preserves the terminal buffer and connection for when we switch back if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } - if (wsRef.current) { - wsRef.current.close() - wsRef.current = null - } + // DO NOT close WebSocket here - keep it alive to preserve buffer return } // Initialize terminal if not already done if (!isInitializedRef.current) { initializeTerminal() - } else { - // Re-fit when becoming active again - setTimeout(() => { - fitTerminal() - }, 0) } - // Connect WebSocket using ref to avoid dependency on connect callback - connectRef.current?.() - }, [isActive, initializeTerminal, fitTerminal]) + // Connect WebSocket if not already connected + // Use double rAF + timeout to ensure terminal is rendered with correct dimensions + // before connecting (the fit/refresh effect handles the actual fitting) + let rafId1: number + let rafId2: number + + const connectIfNeeded = () => { + rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + connectRef.current?.() + } + }) + }) + } + + // Delay connection to ensure terminal dimensions are calculated first + const timeoutId = window.setTimeout(connectIfNeeded, 50) + + return () => { + clearTimeout(timeoutId) + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) + } + }, [isActive, initializeTerminal]) /** - * Fit terminal when isActive becomes true + * Fit and refresh terminal when isActive becomes true */ useEffect(() => { if (isActive && terminalRef.current) { - // Small delay to ensure container is visible - const timeoutId = setTimeout(() => { - fitTerminal() - terminalRef.current?.focus() - }, 100) - return () => clearTimeout(timeoutId) + // Use double requestAnimationFrame to ensure: + // 1. First rAF: style changes are committed + // 2. Second rAF: layout is recalculated + // This is more reliable than setTimeout for visibility changes + let rafId1: number + let rafId2: number + + const handleActivation = () => { + rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + if (terminalRef.current && fitAddonRef.current) { + // Fit terminal to get correct dimensions + fitTerminal() + // Refresh the terminal to redraw content after becoming visible + // This fixes rendering issues when switching between terminals + terminalRef.current.refresh(0, terminalRef.current.rows - 1) + // Focus the terminal to receive keyboard input + terminalRef.current.focus() + } + }) + }) + } + + // Small initial delay to ensure React has committed the style changes + const timeoutId = window.setTimeout(handleActivation, 16) + + return () => { + clearTimeout(timeoutId) + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) + } } }, [isActive, fitTerminal]) @@ -435,25 +503,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) { }, []) /** - * Reconnect when project changes + * Reconnect when project or terminal changes */ useEffect(() => { if (isActive && isInitializedRef.current) { - // Only reconnect if project actually changed, not on initial activation - // This prevents duplicate connect calls when both isActive and projectName effects run - if (lastProjectRef.current === null) { - // Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect) + // Only reconnect if project or terminal actually changed, not on initial activation + // This prevents duplicate connect calls when both isActive and projectName/terminalId effects run + if (lastProjectRef.current === null && lastTerminalIdRef.current === null) { + // Initial activation - just track the project/terminal, don't reconnect (the isActive effect handles initial connect) lastProjectRef.current = projectName + lastTerminalIdRef.current = terminalId return } - if (lastProjectRef.current === projectName) { - // Project didn't change, skip + if (lastProjectRef.current === projectName && lastTerminalIdRef.current === terminalId) { + // Nothing changed, skip return } - // Project changed - update tracking + // Project or terminal changed - update tracking lastProjectRef.current = projectName + lastTerminalIdRef.current = terminalId // Clear terminal and reset cursor position if (terminalRef.current) { @@ -476,10 +546,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { setExitCode(null) reconnectAttempts.current = 0 - // Connect to new project using ref to avoid dependency on connect callback + // Connect to new project/terminal using ref to avoid dependency on connect callback connectRef.current?.() } - }, [projectName, isActive]) + }, [projectName, terminalId, isActive]) return (
@@ -506,6 +576,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { ref={containerRef} className="h-full w-full p-2" style={{ minHeight: '100px' }} + onClick={() => { + // Ensure terminal gets focus when container is clicked + terminalRef.current?.focus() + }} />
) diff --git a/ui/src/components/TerminalTabs.tsx b/ui/src/components/TerminalTabs.tsx new file mode 100644 index 00000000..1a29d373 --- /dev/null +++ b/ui/src/components/TerminalTabs.tsx @@ -0,0 +1,246 @@ +/** + * Terminal Tabs Component + * + * Manages multiple terminal tabs with add, rename, and close functionality. + * Supports inline rename via double-click and context menu. + */ + +import { useState, useRef, useEffect, useCallback } from 'react' +import { Plus, X } from 'lucide-react' +import type { TerminalInfo } from '@/lib/types' + +interface TerminalTabsProps { + terminals: TerminalInfo[] + activeTerminalId: string | null + onSelect: (terminalId: string) => void + onCreate: () => void + onRename: (terminalId: string, newName: string) => void + onClose: (terminalId: string) => void +} + +interface ContextMenuState { + visible: boolean + x: number + y: number + terminalId: string | null +} + +export function TerminalTabs({ + terminals, + activeTerminalId, + onSelect, + onCreate, + onRename, + onClose, +}: TerminalTabsProps) { + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + terminalId: null, + }) + const inputRef = useRef(null) + const contextMenuRef = useRef(null) + + // Focus input when editing starts + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingId]) + + // Close context menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + contextMenuRef.current && + !contextMenuRef.current.contains(e.target as Node) + ) { + setContextMenu((prev) => ({ ...prev, visible: false })) + } + } + + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [contextMenu.visible]) + + // Start editing a terminal name + const startEditing = useCallback((terminal: TerminalInfo) => { + setEditingId(terminal.id) + setEditValue(terminal.name) + setContextMenu((prev) => ({ ...prev, visible: false })) + }, []) + + // Handle edit submission + const submitEdit = useCallback(() => { + if (editingId && editValue.trim()) { + onRename(editingId, editValue.trim()) + } + setEditingId(null) + setEditValue('') + }, [editingId, editValue, onRename]) + + // Cancel editing + const cancelEdit = useCallback(() => { + setEditingId(null) + setEditValue('') + }, []) + + // Handle key events during editing + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + submitEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEdit() + } + }, + [submitEdit, cancelEdit] + ) + + // Handle double-click to start editing + const handleDoubleClick = useCallback( + (terminal: TerminalInfo) => { + startEditing(terminal) + }, + [startEditing] + ) + + // Handle context menu + const handleContextMenu = useCallback( + (e: React.MouseEvent, terminalId: string) => { + e.preventDefault() + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + terminalId, + }) + }, + [] + ) + + // Handle context menu actions + const handleContextMenuRename = useCallback(() => { + if (contextMenu.terminalId) { + const terminal = terminals.find((t) => t.id === contextMenu.terminalId) + if (terminal) { + startEditing(terminal) + } + } + }, [contextMenu.terminalId, terminals, startEditing]) + + const handleContextMenuClose = useCallback(() => { + if (contextMenu.terminalId) { + onClose(contextMenu.terminalId) + } + setContextMenu((prev) => ({ ...prev, visible: false })) + }, [contextMenu.terminalId, onClose]) + + // Handle tab close with confirmation if needed + const handleClose = useCallback( + (e: React.MouseEvent, terminalId: string) => { + e.stopPropagation() + onClose(terminalId) + }, + [onClose] + ) + + return ( +
+ {/* Terminal tabs */} + {terminals.map((terminal) => ( +
onSelect(terminal.id)} + onDoubleClick={() => handleDoubleClick(terminal)} + onContextMenu={(e) => handleContextMenu(e, terminal.id)} + > + {editingId === terminal.id ? ( + setEditValue(e.target.value)} + onBlur={submitEdit} + onKeyDown={handleKeyDown} + className="bg-white text-black px-1 py-0 text-sm font-mono border-2 border-black w-24 outline-none" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {terminal.name} + + )} + + {/* Close button */} + {terminals.length > 1 && ( + + )} +
+ ))} + + {/* Add new terminal button */} + + + {/* Context menu */} + {contextMenu.visible && ( +
+ + {terminals.length > 1 && ( + + )} +
+ )} +
+ ) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 1fdf1469..848326c6 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -23,6 +23,7 @@ import type { ModelsResponse, DevServerStatusResponse, DevServerConfig, + TerminalInfo, } from './types' const API_BASE = '/api' @@ -333,3 +334,41 @@ export async function stopDevServer( export async function getDevServerConfig(projectName: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`) } + +// ============================================================================ +// Terminal API +// ============================================================================ + +export async function listTerminals(projectName: string): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`) +} + +export async function createTerminal( + projectName: string, + name?: string +): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`, { + method: 'POST', + body: JSON.stringify({ name: name ?? null }), + }) +} + +export async function renameTerminal( + projectName: string, + terminalId: string, + name: string +): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }) +} + +export async function deleteTerminal( + projectName: string, + terminalId: string +): Promise { + await fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { + method: 'DELETE', + }) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index df303dbf..cceb704f 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -125,6 +125,13 @@ export interface DevServerConfig { effective_command: string | null } +// Terminal types +export interface TerminalInfo { + id: string + name: string + created_at: string +} + // WebSocket message types export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index fea90a26..9b35f511 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/terminaltabs.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file From f31ea403ea4c31b96fba3ebbb7e7ddd7a7d16043 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 12:25:13 +0200 Subject: [PATCH 027/265] feat: add GLM/alternative API support via environment variables Add support for using alternative API endpoints (like Zhipu AI's GLM models) without affecting the user's global Claude Code settings. Configuration is done via AutoCoder's .env file. Changes: - Add API_ENV_VARS constant and pass through ClaudeAgentOptions.env parameter in client.py and all server service files (spec, expand, assistant sessions) - Add glm_mode to settings API response to indicate when GLM is configured - Add purple "GLM" badge in UI header when GLM mode is active - Update setup status to accept GLM credentials as valid authentication - Update .env.example with GLM configuration documentation - Update README.md with AutoCoder-scoped GLM setup instructions Supported environment variables: - ANTHROPIC_BASE_URL: Custom API endpoint (e.g., https://api.z.ai/api/anthropic) - ANTHROPIC_AUTH_TOKEN: API authentication token - API_TIMEOUT_MS: Request timeout in milliseconds - ANTHROPIC_DEFAULT_SONNET_MODEL: Model override for Sonnet - ANTHROPIC_DEFAULT_OPUS_MODEL: Model override for Opus - ANTHROPIC_DEFAULT_HAIKU_MODEL: Model override for Haiku This approach routes API requests through the alternative endpoint while keeping all Claude Code features (MCP servers, hooks, permissions) intact. Co-Authored-By: Claude Opus 4.5 --- .env.example | 12 ++++++++++ README.md | 18 +++++++-------- client.py | 28 +++++++++++++++++++++++ server/main.py | 7 +++++- server/routers/settings.py | 8 +++++++ server/schemas.py | 1 + server/services/assistant_chat_session.py | 14 ++++++++++++ server/services/expand_chat_session.py | 15 ++++++++++++ server/services/spec_chat_session.py | 16 +++++++++++++ ui/src/App.tsx | 13 ++++++++++- ui/src/hooks/useProjects.ts | 1 + ui/src/lib/types.ts | 1 + 12 files changed, 123 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index c4261004..e29bec38 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,15 @@ # - false: Browser opens a visible window (useful for debugging) # Defaults to 'false' if not specified # PLAYWRIGHT_HEADLESS=false + +# GLM/Alternative API Configuration (Optional) +# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables. +# This only affects AutoCoder - your global Claude Code settings remain unchanged. +# Get an API key at: https://z.ai/subscribe +# +# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic +# ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key +# API_TIMEOUT_MS=3000000 +# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7 +# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7 +# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air diff --git a/README.md b/README.md index 9af81726..3ed7f153 100644 --- a/README.md +++ b/README.md @@ -290,18 +290,18 @@ When test progress increases, the agent sends: ### Using GLM Models (Alternative to Claude) -To use Zhipu AI's GLM models instead of Claude, create a settings file at `~/.claude/settings.json`: +To use Zhipu AI's GLM models instead of Claude, add these variables to your `.env` file in the AutoCoder directory: -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "your-zhipu-api-key" - } -} +```bash +ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic +ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key +API_TIMEOUT_MS=3000000 +ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7 +ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7 +ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air ``` -This routes Claude Code requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models while keeping all Claude Code features (MCP servers, hooks, permissions). +This routes AutoCoder's API requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models. **This only affects AutoCoder** - your global Claude Code settings remain unchanged. Get an API key at: https://z.ai/subscribe diff --git a/client.py b/client.py index f232a0a6..fdf6d54b 100644 --- a/client.py +++ b/client.py @@ -25,6 +25,18 @@ # When False, browser window is visible (default - useful for monitoring agent progress) DEFAULT_PLAYWRIGHT_HEADLESS = False +# Environment variables to pass through to Claude CLI for API configuration +# These allow using alternative API endpoints (e.g., GLM via z.ai) without +# affecting the user's global Claude Code settings +API_ENV_VARS = [ + "ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic) + "ANTHROPIC_AUTH_TOKEN", # API authentication token + "API_TIMEOUT_MS", # Request timeout in milliseconds + "ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet + "ANTHROPIC_DEFAULT_OPUS_MODEL", # Model override for Opus + "ANTHROPIC_DEFAULT_HAIKU_MODEL", # Model override for Haiku +] + def get_playwright_headless() -> bool: """ @@ -205,6 +217,21 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): "args": playwright_args, } + # Build environment overrides for API endpoint configuration + # These override system env vars for the Claude CLI subprocess, + # allowing AutoCoder to use alternative APIs (e.g., GLM) without + # affecting the user's global Claude Code settings + sdk_env = {} + for var in API_ENV_VARS: + value = os.getenv(var) + if value: + sdk_env[var] = value + + if sdk_env: + print(f" - API overrides: {', '.join(sdk_env.keys())}") + if "ANTHROPIC_BASE_URL" in sdk_env: + print(f" - GLM Mode: Using {sdk_env['ANTHROPIC_BASE_URL']}") + return ClaudeSDKClient( options=ClaudeAgentOptions( model=model, @@ -222,5 +249,6 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): max_turns=1000, cwd=str(project_dir.resolve()), settings=str(settings_file.resolve()), # Use absolute path + env=sdk_env, # Pass API configuration overrides to CLI subprocess ) ) diff --git a/server/main.py b/server/main.py index 8be2a50a..9340315f 100644 --- a/server/main.py +++ b/server/main.py @@ -6,6 +6,7 @@ Provides REST API, WebSocket, and static file serving. """ +import os import shutil from contextlib import asynccontextmanager from pathlib import Path @@ -148,7 +149,11 @@ async def setup_status(): # Note: CLI no longer stores credentials in ~/.claude/.credentials.json # The existence of ~/.claude indicates the CLI has been configured claude_dir = Path.home() / ".claude" - credentials = claude_dir.exists() and claude_dir.is_dir() + has_claude_config = claude_dir.exists() and claude_dir.is_dir() + + # If GLM mode is configured via .env, we have alternative credentials + glm_configured = bool(os.getenv("ANTHROPIC_BASE_URL") and os.getenv("ANTHROPIC_AUTH_TOKEN")) + credentials = has_claude_config or glm_configured # Check for Node.js and npm node = shutil.which("node") is not None diff --git a/server/routers/settings.py b/server/routers/settings.py index 18362eea..78d6ff8a 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -6,6 +6,7 @@ Settings are stored in the registry database and shared across all projects. """ +import os import sys from pathlib import Path @@ -33,6 +34,11 @@ def _parse_yolo_mode(value: str | None) -> bool: return (value or "false").lower() == "true" +def _is_glm_mode() -> bool: + """Check if GLM API is configured via environment variables.""" + return bool(os.getenv("ANTHROPIC_BASE_URL")) + + @router.get("/models", response_model=ModelsResponse) async def get_available_models(): """Get list of available models. @@ -54,6 +60,7 @@ async def get_settings(): return SettingsResponse( yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), model=all_settings.get("model", DEFAULT_MODEL), + glm_mode=_is_glm_mode(), ) @@ -71,4 +78,5 @@ async def update_settings(update: SettingsUpdate): return SettingsResponse( yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), model=all_settings.get("model", DEFAULT_MODEL), + glm_mode=_is_glm_mode(), ) diff --git a/server/schemas.py b/server/schemas.py index 72d6bf44..e9b9c31a 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -289,6 +289,7 @@ class SettingsResponse(BaseModel): """Response schema for global settings.""" yolo_mode: bool = False model: str = DEFAULT_MODEL + glm_mode: bool = False # True if GLM API is configured via .env class ModelsResponse(BaseModel): diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 9e067f17..cf182416 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -33,6 +33,16 @@ # Root directory of the project ROOT_DIR = Path(__file__).parent.parent.parent +# Environment variables to pass through to Claude CLI for API configuration +API_ENV_VARS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "API_TIMEOUT_MS", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] + # Read-only feature MCP tools READONLY_FEATURE_MCP_TOOLS = [ "mcp__features__feature_get_stats", @@ -234,6 +244,9 @@ async def start(self) -> AsyncGenerator[dict, None]: # Use system Claude CLI system_cli = shutil.which("claude") + # Build environment overrides for API configuration + sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -246,6 +259,7 @@ async def start(self) -> AsyncGenerator[dict, None]: max_turns=100, cwd=str(self.project_dir.resolve()), settings=str(settings_file.resolve()), + env=sdk_env, ) ) await self.client.__aenter__() diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index b1878047..71e56bb1 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -9,6 +9,7 @@ import asyncio import json import logging +import os import re import shutil import threading @@ -27,6 +28,16 @@ logger = logging.getLogger(__name__) +# Environment variables to pass through to Claude CLI for API configuration +API_ENV_VARS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "API_TIMEOUT_MS", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] + async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: """ @@ -153,6 +164,9 @@ async def start(self) -> AsyncGenerator[dict, None]: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) + # Build environment overrides for API configuration + sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Create Claude SDK client try: self.client = ClaudeSDKClient( @@ -168,6 +182,7 @@ async def start(self) -> AsyncGenerator[dict, None]: max_turns=100, cwd=str(self.project_dir.resolve()), settings=str(settings_file.resolve()), + env=sdk_env, ) ) await self.client.__aenter__() diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index b3b4e1cc..e073a4e6 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -8,6 +8,7 @@ import json import logging +import os import shutil import threading from datetime import datetime @@ -24,6 +25,16 @@ logger = logging.getLogger(__name__) +# Environment variables to pass through to Claude CLI for API configuration +API_ENV_VARS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "API_TIMEOUT_MS", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] + async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: """ @@ -147,6 +158,10 @@ async def start(self) -> AsyncGenerator[dict, None]: # Use Opus for best quality spec generation # Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows system_cli = shutil.which("claude") + + # Build environment overrides for API configuration + sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -163,6 +178,7 @@ async def start(self) -> AsyncGenerator[dict, None]: max_turns=100, cwd=str(self.project_dir.resolve()), settings=str(settings_file.resolve()), + env=sdk_env, ) ) # Enter the async context and track it diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a33b9e7..50b02973 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { useProjects, useFeatures, useAgentStatus } from './hooks/useProjects' +import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects' import { useProjectWebSocket } from './hooks/useWebSocket' import { useFeatureSound } from './hooks/useFeatureSound' import { useCelebration } from './hooks/useCelebration' @@ -46,6 +46,7 @@ function App() { const queryClient = useQueryClient() const { data: projects, isLoading: projectsLoading } = useProjects() const { data: features } = useFeatures(selectedProject) + const { data: settings } = useSettings() useAgentStatus(selectedProject) // Keep polling for status updates const wsState = useProjectWebSocket(selectedProject) @@ -210,6 +211,16 @@ function App() { > + + {/* GLM Mode Badge */} + {settings?.glm_mode && ( + + GLM + + )} )}
diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 6a1098f6..d6081a7b 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -217,6 +217,7 @@ const DEFAULT_MODELS: ModelsResponse = { const DEFAULT_SETTINGS: Settings = { yolo_mode: false, model: 'claude-opus-4-5-20251101', + glm_mode: false, } export function useAvailableModels() { diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index cceb704f..08516173 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -387,6 +387,7 @@ export interface ModelsResponse { export interface Settings { yolo_mode: boolean model: string + glm_mode: boolean } export interface SettingsUpdate { From 9816621e99357fb2929b7dd978449f64ce841e1b Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 13:39:34 +0200 Subject: [PATCH 028/265] Resolve command to long message --- client.py | 5 ++--- server/services/assistant_chat_session.py | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client.py b/client.py index fdf6d54b..7074fef8 100644 --- a/client.py +++ b/client.py @@ -198,9 +198,8 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): "command": sys.executable, # Use the same Python that's running this script "args": ["-m", "mcp_server.feature_mcp"], "env": { - # Inherit parent environment (PATH, ANTHROPIC_API_KEY, etc.) - **os.environ, - # Add custom variables + # Only specify variables the MCP server needs + # (subprocess inherits parent environment automatically) "PROJECT_DIR": str(project_dir.resolve()), "PYTHONPATH": str(Path(__file__).parent.resolve()), }, diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index cf182416..73a17e64 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -231,7 +231,8 @@ async def start(self) -> AsyncGenerator[dict, None]: "command": sys.executable, "args": ["-m", "mcp_server.feature_mcp"], "env": { - **os.environ, + # Only specify variables the MCP server needs + # (subprocess inherits parent environment automatically) "PROJECT_DIR": str(self.project_dir.resolve()), "PYTHONPATH": str(ROOT_DIR.resolve()), }, From 07c2010d32f90c357ae150a12db6362f888f9877 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 14:39:50 +0200 Subject: [PATCH 029/265] fix: write system prompts to file to avoid Windows command line limit Changes: - Write system prompts to CLAUDE.md file instead of passing inline - Use setting_sources=["project"] to load prompts from file - Affects spec_chat_session.py and assistant_chat_session.py Why: - Windows has ~8191 character command line limit - System prompts (e.g., create-spec.md at ~19KB) exceeded this limit - The SDK serializes system_prompt as a CLI argument - Writing to file and using setting_sources bypasses the limit This completes the fix for GitHub issue #33 (Windows "Command Line Too Long"). The previous commit removed **os.environ from MCP configs; this commit addresses the larger system prompt issue. Co-Authored-By: Claude Opus 4.5 --- server/services/assistant_chat_session.py | 11 ++++++++++- server/services/spec_chat_session.py | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 73a17e64..b0051b89 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -242,6 +242,13 @@ async def start(self) -> AsyncGenerator[dict, None]: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) + # Write system prompt to CLAUDE.md file to avoid Windows command line length limit + # The SDK will read this via setting_sources=["project"] + claude_md_path = self.project_dir / "CLAUDE.md" + with open(claude_md_path, "w", encoding="utf-8") as f: + f.write(system_prompt) + logger.info(f"Wrote assistant system prompt to {claude_md_path}") + # Use system Claude CLI system_cli = shutil.which("claude") @@ -253,7 +260,9 @@ async def start(self) -> AsyncGenerator[dict, None]: options=ClaudeAgentOptions( model="claude-opus-4-5-20251101", cli_path=system_cli, - system_prompt=system_prompt, + # System prompt loaded from CLAUDE.md via setting_sources + # This avoids Windows command line length limit (~8191 chars) + setting_sources=["project"], allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], mcp_servers=mcp_servers, permission_mode="bypassPermissions", diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index e073a4e6..1f0d8cc8 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -154,6 +154,13 @@ async def start(self) -> AsyncGenerator[dict, None]: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) + # Write system prompt to CLAUDE.md file to avoid Windows command line length limit + # The SDK will read this via setting_sources=["project"] + claude_md_path = self.project_dir / "CLAUDE.md" + with open(claude_md_path, "w", encoding="utf-8") as f: + f.write(system_prompt) + logger.info(f"Wrote system prompt to {claude_md_path}") + # Create Claude SDK client with limited tools for spec creation # Use Opus for best quality spec generation # Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows @@ -167,7 +174,9 @@ async def start(self) -> AsyncGenerator[dict, None]: options=ClaudeAgentOptions( model="claude-opus-4-5-20251101", cli_path=system_cli, - system_prompt=system_prompt, + # System prompt loaded from CLAUDE.md via setting_sources + # This avoids Windows command line length limit (~8191 chars) + setting_sources=["project"], allowed_tools=[ "Read", "Write", From 3d97cbf24bfafdfb8d8c856121696811f80da3be Mon Sep 17 00:00:00 2001 From: Al Sharma <9090916+kunalnano@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:34:42 -0600 Subject: [PATCH 030/265] fix: exit agent loop when all features pass Previously, the autonomous agent would continue running indefinitely even after all features passed verification. The agent would enter a verification loop, repeatedly checking 'All features are passing!' without ever exiting. This fix detects the completion message from feature_get_next() and gracefully exits the main loop with a victory banner, preventing unnecessary API calls and resource consumption. Fixes infinite loop when project reaches 100% completion. --- agent.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/agent.py b/agent.py index 50edc46d..f1206e24 100644 --- a/agent.py +++ b/agent.py @@ -14,7 +14,7 @@ from typing import Optional from zoneinfo import ZoneInfo -from claude_agent_sdk import ClaudeSDKClient +from claude_code_sdk import ClaudeSDKClient # Fix Windows console encoding for Unicode characters (emoji, etc.) # Without this, print() crashes when Claude outputs emoji like ✅ @@ -196,6 +196,14 @@ async def run_autonomous_agent( async with client: status, response = await run_agent_session(client, prompt, project_dir) + # Check for project completion - EXIT when all features pass + if "all features are passing" in response.lower() or "no more work to do" in response.lower(): + print("\n" + "=" * 70) + print(" 🎉 PROJECT COMPLETE - ALL FEATURES PASSING!") + print("=" * 70) + print_progress_summary(project_dir) + break + # Handle status if status == "continue": delay_seconds = AUTO_CONTINUE_DELAY_SECONDS From 3c97051122b70d10ebe5396d515f0c860151e193 Mon Sep 17 00:00:00 2001 From: Quenos Date: Tue, 13 Jan 2026 11:47:46 +0100 Subject: [PATCH 031/265] fix: make boolean fields resilient to NULL values Problem: Features with NULL values in passes/in_progress fields caused Pydantic validation errors in the API. Solution - defense in depth: 1. Database model: Add nullable=False to passes and in_progress columns 2. Migration: Auto-fix existing NULL values to False on database connect 3. API layer: Handle NULL gracefully in feature_to_response (treat as False) 4. MCP server: Explicitly set in_progress=False when creating features This ensures: - New databases cannot have NULL boolean fields - Existing databases are auto-migrated on connect - Even if NULL values exist, they're handled gracefully at runtime Co-Authored-By: Claude Opus 4.5 --- api/database.py | 24 +++++++++++++++++++----- mcp_server/feature_mcp.py | 2 ++ server/routers/features.py | 10 +++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/api/database.py b/api/database.py index a74b857a..69a919b9 100644 --- a/api/database.py +++ b/api/database.py @@ -27,8 +27,8 @@ class Feature(Base): name = Column(String(255), nullable=False) description = Column(Text, nullable=False) steps = Column(JSON, nullable=False) # Stored as JSON array - passes = Column(Boolean, default=False, index=True) - in_progress = Column(Boolean, default=False, index=True) + passes = Column(Boolean, nullable=False, default=False, index=True) + in_progress = Column(Boolean, nullable=False, default=False, index=True) def to_dict(self) -> dict: """Convert feature to dictionary for JSON serialization.""" @@ -39,8 +39,9 @@ def to_dict(self) -> dict: "name": self.name, "description": self.description, "steps": self.steps, - "passes": self.passes, - "in_progress": self.in_progress, + # Handle legacy NULL values gracefully - treat as False + "passes": self.passes if self.passes is not None else False, + "in_progress": self.in_progress if self.in_progress is not None else False, } @@ -73,6 +74,18 @@ def _migrate_add_in_progress_column(engine) -> None: conn.commit() +def _migrate_fix_null_boolean_fields(engine) -> None: + """Fix NULL values in passes and in_progress columns.""" + from sqlalchemy import text + + with engine.connect() as conn: + # Fix NULL passes values + conn.execute(text("UPDATE features SET passes = 0 WHERE passes IS NULL")) + # Fix NULL in_progress values + conn.execute(text("UPDATE features SET in_progress = 0 WHERE in_progress IS NULL")) + conn.commit() + + def create_database(project_dir: Path) -> tuple: """ Create database and return engine + session maker. @@ -87,8 +100,9 @@ def create_database(project_dir: Path) -> tuple: engine = create_engine(db_url, connect_args={"check_same_thread": False}) Base.metadata.create_all(bind=engine) - # Migrate existing databases to add in_progress column + # Migrate existing databases _migrate_add_in_progress_column(engine) + _migrate_fix_null_boolean_fields(engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) return engine, SessionLocal diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 1534bc1b..2af499f9 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -409,6 +409,7 @@ def feature_create_bulk( description=feature_data["description"], steps=feature_data["steps"], passes=False, + in_progress=False, ) session.add(db_feature) created_count += 1 @@ -459,6 +460,7 @@ def feature_create( description=description, steps=steps, passes=False, + in_progress=False, ) session.add(db_feature) session.commit() diff --git a/server/routers/features.py b/server/routers/features.py index ce0f388d..1d02cca5 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -72,7 +72,10 @@ def get_db_session(project_dir: Path): def feature_to_response(f) -> FeatureResponse: - """Convert a Feature model to a FeatureResponse.""" + """Convert a Feature model to a FeatureResponse. + + Handles legacy NULL values in boolean fields by treating them as False. + """ return FeatureResponse( id=f.id, priority=f.priority, @@ -80,8 +83,9 @@ def feature_to_response(f) -> FeatureResponse: name=f.name, description=f.description, steps=f.steps if isinstance(f.steps, list) else [], - passes=f.passes, - in_progress=f.in_progress, + # Handle legacy NULL values gracefully - treat as False + passes=f.passes if f.passes is not None else False, + in_progress=f.in_progress if f.in_progress is not None else False, ) From 29715f2136410ce1832eab66825ad2018ec676a8 Mon Sep 17 00:00:00 2001 From: simfor99 Date: Tue, 13 Jan 2026 21:00:57 +0100 Subject: [PATCH 032/265] feat: add GSD integration skill for existing projects Add skill to convert GSD codebase mapping (.planning/codebase/*.md) to Autocoder app_spec.txt format. This enables onboarding existing projects to Autocoder without manually writing the XML spec. Workflow: 1. Run /gsd:map-codebase on existing project 2. Run /gsd-to-autocoder-spec to generate app_spec.txt 3. Start Autocoder normally Closes #55 Co-Authored-By: Claude Opus 4.5 --- .claude/commands/gsd-to-autocoder-spec.md | 10 + .claude/skills/gsd-to-autocoder-spec/SKILL.md | 221 +++++++++++++ .../references/app-spec-format.md | 293 ++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 .claude/commands/gsd-to-autocoder-spec.md create mode 100644 .claude/skills/gsd-to-autocoder-spec/SKILL.md create mode 100644 .claude/skills/gsd-to-autocoder-spec/references/app-spec-format.md diff --git a/.claude/commands/gsd-to-autocoder-spec.md b/.claude/commands/gsd-to-autocoder-spec.md new file mode 100644 index 00000000..fc41ceee --- /dev/null +++ b/.claude/commands/gsd-to-autocoder-spec.md @@ -0,0 +1,10 @@ +--- +allowed-tools: Read, Write, Bash, Glob, Grep +description: Convert GSD codebase mapping to Autocoder app_spec.txt +--- + +# GSD to Autocoder Spec + +Convert `.planning/codebase/*.md` (from `/gsd:map-codebase`) to Autocoder's `prompts/app_spec.txt`. + +@.claude/skills/gsd-to-autocoder-spec/SKILL.md diff --git a/.claude/skills/gsd-to-autocoder-spec/SKILL.md b/.claude/skills/gsd-to-autocoder-spec/SKILL.md new file mode 100644 index 00000000..d4fba246 --- /dev/null +++ b/.claude/skills/gsd-to-autocoder-spec/SKILL.md @@ -0,0 +1,221 @@ +--- +name: gsd-to-autocoder-spec +description: | + Convert GSD codebase mapping to Autocoder app_spec.txt. This skill should be used when + the user has run /gsd:map-codebase and wants to use Autocoder on an existing project. + Triggers: "convert to autocoder", "gsd to spec", "create app_spec from codebase", + "use autocoder on existing project", after /gsd:map-codebase completion. +--- + +# GSD to Autocoder Spec Converter + +Converts `.planning/codebase/*.md` (GSD mapping output) to `prompts/app_spec.txt` (Autocoder format). + +## When to Use + +- After running `/gsd:map-codebase` on an existing project +- When onboarding an existing codebase to Autocoder +- User wants Autocoder to continue development on existing code + +## Prerequisites + +The project must have `.planning/codebase/` with these files: +- `STACK.md` - Technology stack (required) +- `ARCHITECTURE.md` - Code architecture (required) +- `STRUCTURE.md` - Directory layout (required) +- `CONVENTIONS.md` - Code conventions (optional) +- `INTEGRATIONS.md` - External services (optional) + +## Process + + +### Step 1: Verify GSD Mapping Exists + +```bash +ls -la .planning/codebase/ +``` + +**Required files:** STACK.md, ARCHITECTURE.md, STRUCTURE.md + +If `.planning/codebase/` doesn't exist: +``` +GSD codebase mapping not found. + +Run /gsd:map-codebase first to analyze the existing codebase. +``` +Stop workflow. + + + +### Step 2: Read Codebase Documentation + +Read all available GSD documents: + +```bash +cat .planning/codebase/STACK.md +cat .planning/codebase/ARCHITECTURE.md +cat .planning/codebase/STRUCTURE.md +cat .planning/codebase/CONVENTIONS.md 2>/dev/null || true +cat .planning/codebase/INTEGRATIONS.md 2>/dev/null || true +``` + +Extract key information: +- **From STACK.md:** Languages, frameworks, dependencies, runtime, ports +- **From ARCHITECTURE.md:** Patterns, layers, data flow, entry points +- **From STRUCTURE.md:** Directory layout, key file locations, naming conventions +- **From INTEGRATIONS.md:** External APIs, services, databases + + + +### Step 3: Extract Project Metadata + +```bash +cat package.json 2>/dev/null | head -20 || echo "No package.json" +``` + +Extract: +- Project name +- Version +- Main dependencies + + + +### Step 4: Generate app_spec.txt + +Create `prompts/` directory: +```bash +mkdir -p prompts +``` + +**Mapping GSD Documents to Autocoder Spec:** + +| GSD Source | Autocoder Target | +|------------|------------------| +| STACK.md Languages | `` | +| STACK.md Frameworks | ``, `` | +| STACK.md Dependencies | `` | +| ARCHITECTURE.md Layers | `` categories | +| ARCHITECTURE.md Data Flow | `` | +| ARCHITECTURE.md Entry Points | `` | +| STRUCTURE.md Layout | `` (if frontend) | +| INTEGRATIONS.md APIs | `` | +| INTEGRATIONS.md Services | `` | + +**Feature Generation Guidelines:** + +1. Analyze existing code structure to infer implemented features +2. Each feature must be testable: "User can...", "System displays...", "API returns..." +3. Group features by category matching architecture layers +4. Target feature counts by complexity: + - Simple CLI/utility: ~100-150 features + - Medium web app: ~200-250 features + - Complex full-stack: ~300-400 features + +**Write the spec file** using the XML format from [references/app-spec-format.md](references/app-spec-format.md): + +```bash +cat > prompts/app_spec.txt << 'EOF' + + {from package.json or directory} + + + {Synthesized from ARCHITECTURE.md overview} + + + + + {from STACK.md} + {from STACK.md} + {from STACK.md or default 3000} + + + {from STACK.md} + {from STACK.md or INTEGRATIONS.md} + {from STACK.md or default 3001} + + + + + + {from STACK.md Runtime + INTEGRATIONS.md requirements} + + + + + + <{layer_name}> + - {Feature derived from code analysis} + - {Feature derived from code analysis} + + + + + {from INTEGRATIONS.md or inferred from STRUCTURE.md routes/} + + + + {from ARCHITECTURE.md Data Flow} + + + + + - All existing features continue working + - New features integrate seamlessly + - No regression in core functionality + + + +EOF +``` + + + +### Step 5: Verify Generated Spec + +```bash +head -100 prompts/app_spec.txt +echo "---" +grep -c "User can\|System\|API\|Feature" prompts/app_spec.txt || echo "0" +``` + +**Validation checklist:** +- [ ] `` root tag present +- [ ] `` matches actual project +- [ ] `` reflects STACK.md +- [ ] `` has categorized features +- [ ] Features are specific and testable + + + +### Step 6: Report Completion + +Output: +``` +app_spec.txt generated from GSD codebase mapping. + +Source: .planning/codebase/*.md +Output: prompts/app_spec.txt + +Next: Start Autocoder + + cd {project_dir} + python ~/projects/autocoder/start.py + +Or via UI: + ~/projects/autocoder/start_ui.sh + +The Initializer will create features.db from this spec. +``` + + +## XML Format Reference + +See [references/app-spec-format.md](references/app-spec-format.md) for complete XML structure with all sections. + +## Error Handling + +| Error | Resolution | +|-------|------------| +| No .planning/codebase/ | Run `/gsd:map-codebase` first | +| Missing required files | Re-run GSD mapping | +| Cannot infer features | Ask user for clarification | diff --git a/.claude/skills/gsd-to-autocoder-spec/references/app-spec-format.md b/.claude/skills/gsd-to-autocoder-spec/references/app-spec-format.md new file mode 100644 index 00000000..fa5f9c80 --- /dev/null +++ b/.claude/skills/gsd-to-autocoder-spec/references/app-spec-format.md @@ -0,0 +1,293 @@ +# Autocoder app_spec.txt XML Format + +Complete reference for the XML structure expected by Autocoder's Initializer agent. + +## Root Structure + +```xml + + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + +``` + +## Section Details + +### project_name +```xml +my-awesome-app +``` +Simple string, typically from package.json name field. + +### overview +```xml + + A brief 2-3 sentence description of what the project does, + what problem it solves, and who it's for. + +``` + +### technology_stack +```xml + + + React with Vite + Tailwind CSS + React hooks and context + React Router + 3000 + + + Node.js with Express + SQLite with better-sqlite3 + 3001 + + + RESTful endpoints + + +``` + +### prerequisites +```xml + + + - Node.js 18+ installed + - npm or pnpm package manager + - Required API keys: OPENAI_API_KEY, etc. + + +``` + +### core_features (CRITICAL) + +This is where features are defined. Each feature becomes a test case in features.db. + +```xml + + + - User can register with email/password + - User can login and receive session token + - User can logout and invalidate session + - User can reset password via email link + - System redirects unauthenticated users to login + + + + - User can view summary statistics on dashboard + - Dashboard displays recent activity list + - User can click items to navigate to detail view + - Dashboard updates in real-time when data changes + + + + - User can create new items via form + - User can view list of items with pagination + - User can edit existing items + - User can delete items with confirmation dialog + - User can search items by keyword + - User can filter items by category + - User can sort items by date/name/status + + + + - API returns 401 for unauthenticated requests + - API returns 403 for unauthorized actions + - API validates input and returns 400 for invalid data + - API returns paginated results for list endpoints + + + + - UI is responsive on mobile (375px width) + - UI is responsive on tablet (768px width) + - UI displays loading states during async operations + - UI shows toast notifications for actions + - UI handles errors gracefully with user feedback + + +``` + +**Feature Writing Rules:** +1. Start with action verb: "User can...", "System displays...", "API returns..." +2. Be specific and testable +3. One behavior per feature +4. Group by functional area + +### database_schema +```xml + + + + - id (PRIMARY KEY) + - email (UNIQUE, NOT NULL) + - password_hash (NOT NULL) + - name + - created_at, updated_at + + + - id (PRIMARY KEY) + - user_id (FOREIGN KEY -> users.id) + - title (NOT NULL) + - description + - status (enum: draft, active, archived) + - created_at, updated_at + + + +``` + +### api_endpoints_summary +```xml + + + - POST /api/auth/register + - POST /api/auth/login + - POST /api/auth/logout + - GET /api/auth/me + + + - GET /api/items (list with pagination) + - POST /api/items (create) + - GET /api/items/:id (get single) + - PUT /api/items/:id (update) + - DELETE /api/items/:id (delete) + + +``` + +### ui_layout +```xml + + + - Header with navigation and user menu + - Sidebar for navigation (collapsible on mobile) + - Main content area + - Footer (optional) + + + - Logo at top + - Navigation links + - User profile at bottom + + +``` + +### design_system +```xml + + + - Primary: #3B82F6 (blue) + - Background: #FFFFFF (light), #1A1A1A (dark) + - Text: #1F2937 (light), #E5E5E5 (dark) + - Error: #EF4444 + - Success: #10B981 + + + - Font family: Inter, system-ui, sans-serif + - Headings: font-semibold + - Body: font-normal + + +``` + +### key_interactions +```xml + + + 1. User navigates to /login + 2. User enters email and password + 3. System validates credentials + 4. On success: redirect to dashboard + 5. On failure: show error message + + + 1. User clicks "Create New" button + 2. Modal form opens + 3. User fills required fields + 4. User clicks save + 5. Item appears in list with success toast + + +``` + +### implementation_steps +```xml + + + Project Setup + + - Initialize frontend with Vite + - Set up Express backend + - Create database schema + - Configure environment variables + + + + Authentication + + - Implement registration + - Implement login/logout + - Add session management + - Create protected routes + + + +``` + +### success_criteria +```xml + + + - All features work as specified + - No console errors in browser + - Data persists correctly in database + + + - Responsive on all device sizes + - Fast load times (< 2s) + - Clear feedback for all actions + + + - Clean code structure + - Proper error handling + - Secure authentication + + +``` + +## Feature Count Guidelines + +The Initializer agent expects features distributed across categories: + +| Project Complexity | Total Features | Categories | +|--------------------|----------------|------------| +| Simple CLI/utility | 100-150 | 5-8 | +| Medium web app | 200-250 | 10-15 | +| Complex full-stack | 300-400 | 15-20 | + +## GSD to Autocoder Mapping + +When converting from GSD codebase mapping: + +| GSD Document | Maps To | +|--------------|---------| +| STACK.md Languages | `` | +| STACK.md Runtime | `` | +| STACK.md Frameworks | ``, `` | +| ARCHITECTURE.md Pattern | `` | +| ARCHITECTURE.md Layers | `` categories | +| ARCHITECTURE.md Data Flow | `` | +| ARCHITECTURE.md Entry Points | `` | +| STRUCTURE.md Layout | Informs feature organization | +| INTEGRATIONS.md APIs | `` | +| INTEGRATIONS.md Services | `` | From bc7970f5bf8eb31b80f9845f96f9ea749dd2d0d9 Mon Sep 17 00:00:00 2001 From: simfor99 Date: Tue, 13 Jan 2026 22:20:14 +0100 Subject: [PATCH 033/265] fix: use ANTHROPIC_DEFAULT_OPUS_MODEL env var for model selection The model was hardcoded to "claude-opus-4-5-20251101" in chat session services, ignoring the ANTHROPIC_DEFAULT_OPUS_MODEL environment variable. This caused issues when using alternative API providers (e.g., GLM via z.ai) that don't support Claude model names. Fixes #51 Co-Authored-By: Claude Opus 4.5 --- server/services/assistant_chat_session.py | 6 +++++- server/services/expand_chat_session.py | 6 +++++- server/services/spec_chat_session.py | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index b0051b89..a7f00ca1 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -255,10 +255,14 @@ async def start(self) -> AsyncGenerator[dict, None]: # Build environment overrides for API configuration sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Determine model from environment or use default + # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names + model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") + try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( - model="claude-opus-4-5-20251101", + model=model, cli_path=system_cli, # System prompt loaded from CLAUDE.md via setting_sources # This avoids Windows command line length limit (~8191 chars) diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 71e56bb1..3c4008bd 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -167,11 +167,15 @@ async def start(self) -> AsyncGenerator[dict, None]: # Build environment overrides for API configuration sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Determine model from environment or use default + # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names + model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") + # Create Claude SDK client try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( - model="claude-opus-4-5-20251101", + model=model, cli_path=system_cli, system_prompt=system_prompt, allowed_tools=[ diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 1f0d8cc8..818179da 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -169,10 +169,14 @@ async def start(self) -> AsyncGenerator[dict, None]: # Build environment overrides for API configuration sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Determine model from environment or use default + # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names + model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") + try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( - model="claude-opus-4-5-20251101", + model=model, cli_path=system_cli, # System prompt loaded from CLAUDE.md via setting_sources # This avoids Windows command line length limit (~8191 chars) From c2f98482362781f6c2c98c095cc973fc11241ef4 Mon Sep 17 00:00:00 2001 From: simfor99 Date: Wed, 14 Jan 2026 07:56:11 +0100 Subject: [PATCH 034/265] fix: add execute permission to shell scripts Makes start.sh and start_ui.sh executable for Unix/Linux/macOS users. Co-Authored-By: Claude Opus 4.5 --- start.sh | 0 start_ui.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 start.sh mode change 100644 => 100755 start_ui.sh diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 diff --git a/start_ui.sh b/start_ui.sh old mode 100644 new mode 100755 From d1b8eb5f9933df05384e99adbea0989dce0ef34b Mon Sep 17 00:00:00 2001 From: Auto Date: Wed, 14 Jan 2026 14:54:53 +0200 Subject: [PATCH 035/265] feat: add feature editing capability for pending/in-progress features Add the ability for users to edit features that are not yet completed, allowing them to provide corrections or additional instructions when the agent is stuck or implementing a feature incorrectly. Backend changes: - Add FeatureUpdate schema in server/schemas.py with optional fields - Add PATCH /api/projects/{project_name}/features/{feature_id} endpoint - Validate that completed features (passes=True) cannot be edited Frontend changes: - Add FeatureUpdate type in ui/src/lib/types.ts - Add updateFeature() API function in ui/src/lib/api.ts - Add useUpdateFeature() React Query mutation hook - Create EditFeatureForm.tsx component with pre-filled form values - Update FeatureModal.tsx with Edit button for non-completed features The edit form allows modifying category, name, description, priority, and test steps. Save button is disabled until changes are detected. Co-Authored-By: Claude Opus 4.5 --- server/routers/features.py | 58 ++++++ server/schemas.py | 9 + ui/src/components/EditFeatureForm.tsx | 248 ++++++++++++++++++++++++++ ui/src/components/FeatureModal.tsx | 26 ++- ui/src/hooks/useProjects.ts | 14 +- ui/src/lib/api.ts | 12 ++ ui/src/lib/types.ts | 8 + ui/tsconfig.tsbuildinfo | 2 +- 8 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/EditFeatureForm.tsx diff --git a/server/routers/features.py b/server/routers/features.py index ce0f388d..bc6353c9 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -17,6 +17,7 @@ FeatureCreate, FeatureListResponse, FeatureResponse, + FeatureUpdate, ) from ..utils.validation import validate_project_name @@ -217,6 +218,63 @@ async def get_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Database error occurred") +@router.patch("/{feature_id}", response_model=FeatureResponse) +async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate): + """ + Update a feature's details. + + Only features that are not yet completed (passes=False) can be edited. + This allows users to provide corrections or additional instructions + when the agent is stuck or implementing a feature incorrectly. + """ + 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="Project directory not found") + + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + + # Prevent editing completed features + if feature.passes: + raise HTTPException( + status_code=400, + detail="Cannot edit a completed feature. Features marked as done are immutable." + ) + + # Apply updates for non-None fields + if update.category is not None: + feature.category = update.category + if update.name is not None: + feature.name = update.name + if update.description is not None: + feature.description = update.description + if update.steps is not None: + feature.steps = update.steps + if update.priority is not None: + feature.priority = update.priority + + session.commit() + session.refresh(feature) + + return feature_to_response(feature) + except HTTPException: + raise + except Exception: + logger.exception("Failed to update feature") + raise HTTPException(status_code=500, detail="Failed to update feature") + + @router.delete("/{feature_id}") async def delete_feature(project_name: str, feature_id: int): """Delete a feature.""" diff --git a/server/schemas.py b/server/schemas.py index e9b9c31a..968cb6f5 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,6 +87,15 @@ class FeatureCreate(FeatureBase): priority: int | None = None +class FeatureUpdate(BaseModel): + """Request schema for updating a feature (partial updates allowed).""" + category: str | None = None + name: str | None = None + description: str | None = None + steps: list[str] | None = None + priority: int | None = None + + class FeatureResponse(FeatureBase): """Response schema for a feature.""" id: int diff --git a/ui/src/components/EditFeatureForm.tsx b/ui/src/components/EditFeatureForm.tsx new file mode 100644 index 00000000..2e9c5b48 --- /dev/null +++ b/ui/src/components/EditFeatureForm.tsx @@ -0,0 +1,248 @@ +import { useState, useId } from 'react' +import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' +import { useUpdateFeature } from '../hooks/useProjects' +import type { Feature } from '../lib/types' + +interface Step { + id: string + value: string +} + +interface EditFeatureFormProps { + feature: Feature + projectName: string + onClose: () => void + onSaved: () => void +} + +export function EditFeatureForm({ feature, projectName, onClose, onSaved }: EditFeatureFormProps) { + const formId = useId() + const [category, setCategory] = useState(feature.category) + const [name, setName] = useState(feature.name) + const [description, setDescription] = useState(feature.description) + const [priority, setPriority] = useState(String(feature.priority)) + const [steps, setSteps] = useState(() => + feature.steps.length > 0 + ? feature.steps.map((step, i) => ({ id: `${formId}-step-${i}`, value: step })) + : [{ id: `${formId}-step-0`, value: '' }] + ) + const [error, setError] = useState(null) + const [stepCounter, setStepCounter] = useState(feature.steps.length || 1) + + const updateFeature = useUpdateFeature(projectName) + + const handleAddStep = () => { + setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }]) + setStepCounter(stepCounter + 1) + } + + const handleRemoveStep = (id: string) => { + setSteps(steps.filter(step => step.id !== id)) + } + + const handleStepChange = (id: string, value: string) => { + setSteps(steps.map(step => + step.id === id ? { ...step, value } : step + )) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const filteredSteps = steps + .map(s => s.value.trim()) + .filter(s => s.length > 0) + + try { + await updateFeature.mutateAsync({ + featureId: feature.id, + update: { + category: category.trim(), + name: name.trim(), + description: description.trim(), + steps: filteredSteps, + priority: parseInt(priority, 10), + }, + }) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update feature') + } + } + + const isValid = category.trim() && name.trim() && description.trim() + + // Check if any changes were made + const currentSteps = steps.map(s => s.value.trim()).filter(s => s) + const hasChanges = + category.trim() !== feature.category || + name.trim() !== feature.name || + description.trim() !== feature.description || + parseInt(priority, 10) !== feature.priority || + JSON.stringify(currentSteps) !== JSON.stringify(feature.steps) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ Edit Feature +

+ +
+ + {/* Form */} +
+ {/* Error Message */} + {error && ( +
+ + {error} + +
+ )} + + {/* Category & Priority Row */} +
+
+ + setCategory(e.target.value)} + placeholder="e.g., Authentication, UI, API" + className="neo-input" + required + /> +
+
+ + setPriority(e.target.value)} + min="1" + className="neo-input" + required + /> +
+
+ + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g., User login form" + className="neo-input" + required + /> +
+ + {/* Description */} +
+ +