   150→            exists=True,
   151→            status="error",
   152→            feature_count=None,
   153→            timestamp=None,
   154→            files_written=[],
   155→        )
   156→    except Exception as e:
   157→        logger.error(f"Error reading spec status file: {e}")
   158→        raise HTTPException(status_code=500, detail="Failed to read status file")
   159→
   160→
   161→# ============================================================================
   162→# WebSocket Endpoint
   163→# ============================================================================
   164→
   165→@router.websocket("/ws/{project_name}")
   166→async def spec_chat_websocket(websocket: WebSocket, project_name: str):
   167→    """
   168→    WebSocket endpoint for interactive spec creation chat.
   169→
   170→    Message protocol:
   171→
   172→    Client -> Server:
   173→    - {"type": "start"} - Start the spec creation session
   174→    - {"type": "message", "content": "..."} - Send user message
   175→    - {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question
   176→    - {"type": "ping"} - Keep-alive ping
   177→
   178→    Server -> Client:
   179→    - {"type": "text", "content": "..."} - Text chunk from Claude
   180→    - {"type": "question", "questions": [...], "tool_id": "..."} - Structured question
   181→    - {"type": "spec_complete", "path": "..."} - Spec file created
   182→    - {"type": "file_written", "path": "..."} - Other file written
   183→    - {"type": "complete"} - Session complete
   184→    - {"type": "error", "content": "..."} - Error message
   185→    - {"type": "pong"} - Keep-alive pong
   186→    """
   187→    if not validate_project_name(project_name):
   188→        await websocket.close(code=4000, reason="Invalid project name")
   189→        return
   190→
   191→    # Look up project directory from registry
   192→    project_dir = _get_project_path(project_name)
   193→    if not project_dir:
   194→        await websocket.close(code=4004, reason="Project not found in registry")
   195→        return
   196→
   197→    if not project_dir.exists():
   198→        await websocket.close(code=4004, reason="Project directory not found")
   199→        return
   200→
   201→    await websocket.accept()
   202→
   203→    session: Optional[SpecChatSession] = None
   204→
   205→    try:
   206→        while True:
   207→            try:
   208→                # Receive message from client
   209→                data = await websocket.receive_text()
   210→                message = json.loads(data)
   211→                msg_type = message.get("type")
   212→
   213→                if msg_type == "ping":
   214→                    await websocket.send_json({"type": "pong"})
   215→                    continue
   216→
   217→                elif msg_type == "start":
   218→                    # Create and start a new session
   219→                    # Check for import mode parameters
   220→                    is_import = message.get("is_import", False)
   221→                    codebase_analysis = message.get("codebase_analysis")
   222→
   223→                    session = await create_session(
   224→                        project_name,
   225→                        project_dir,
   226→                        is_import=is_import,
   227→                        codebase_analysis=codebase_analysis,
   228→                    )
   229→
   230→                    # Track spec completion state
   231→                    spec_complete_received = False
   232→                    spec_path = None
   233→
   234→                    # Stream the initial greeting
   235→                    async for chunk in session.start():
   236→                        # Track spec_complete but don't send complete yet
   237→                        if chunk.get("type") == "spec_complete":
   238→                            spec_complete_received = True
   239→                            spec_path = chunk.get("path")
   240→                            await websocket.send_json(chunk)
   241→                            continue
   242→
   243→                        # When response_done arrives, send complete if spec was done
   244→                        if chunk.get("type") == "response_done":
   245→                            await websocket.send_json(chunk)
   246→                            if spec_complete_received:
   247→                                await websocket.send_json({"type": "complete", "path": spec_path})
   248→                            continue
   249→
   250→                        await websocket.send_json(chunk)
   251→
   252→                elif msg_type == "message":
   253→                    # User sent a message
   254→                    if not session:
   255→                        session = get_session(project_name)
   256→                        if not session:
   257→                            await websocket.send_json({
   258→                                "type": "error",
   259→                                "content": "No active session. Send 'start' first."
   260→                            })
   261→                            continue
   262→
   263→                    user_content = message.get("content", "").strip()
   264→
   265→                    # Parse attachments if present
   266→                    attachments: list[ImageAttachment] = []
   267→                    raw_attachments = message.get("attachments", [])
   268→                    if raw_attachments:
   269→                        try:
   270→                            for raw_att in raw_attachments:
   271→                                attachments.append(ImageAttachment(**raw_att))
   272→                        except (ValidationError, Exception) as e:
   273→                            logger.warning(f"Invalid attachment data: {e}")
   274→                            await websocket.send_json({
   275→                                "type": "error",
   276→                                "content": f"Invalid attachment: {str(e)}"
   277→                            })
   278→                            continue
   279→
   280→                    # Allow empty content if attachments are present
   281→                    if not user_content and not attachments:
   282→                        await websocket.send_json({
   283→                            "type": "error",
   284→                            "content": "Empty message"
   285→                        })
   286→                        continue
   287→
   288→                    # Track spec completion state
   289→                    spec_complete_received = False
   290→                    spec_path = None
   291→
   292→                    # Stream Claude's response (with attachments if present)
   293→                    async for chunk in session.send_message(user_content, attachments if attachments else None):
   294→                        # Track spec_complete but don't send complete yet
   295→                        if chunk.get("type") == "spec_complete":
   296→                            spec_complete_received = True
   297→                            spec_path = chunk.get("path")
   298→                            await websocket.send_json(chunk)
   299→                            continue

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

            exists=True,
            status="error",
            feature_count=None,
            timestamp=None,
            files_written=[],
        )
    except Exception as e:
        logger.error(f"Error reading spec status file: {e}")
        raise HTTPException(status_code=500, detail="Failed to read status file")

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@router.websocket("/ws/{project_name}")
async def spec_chat_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for interactive spec creation chat.

    Message protocol:

    Client -> Server:
    - {"type": "start"} - Start the spec creation session
    - {"type": "message", "content": "..."} - Send user message
    - {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "text", "content": "..."} - Text chunk from Claude
    - {"type": "question", "questions": [...], "tool_id": "..."} - Structured question
    - {"type": "spec_complete", "path": "..."} - Spec file created
    - {"type": "file_written", "path": "..."} - Other file written
    - {"type": "complete"} - Session complete
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, 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=4004, reason="Project not found in registry")
        return

    if not project_dir.exists():
        await websocket.close(code=4004, reason="Project directory not found")
        return

    await websocket.accept()

    session: Optional[SpecChatSession] = None

    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"})
                    continue

                elif msg_type == "start":
                    # Create and start a new session
                    # Check for import mode parameters
                    is_import = message.get("is_import", False)
                    codebase_analysis = message.get("codebase_analysis")

                    session = await create_session(
                        project_name,
                        project_dir,
                        is_import=is_import,
                        codebase_analysis=codebase_analysis,
                    )

                    # Track spec completion state
                    spec_complete_received = False
                    spec_path = None

                    # Stream the initial greeting
                    async for chunk in session.start():
                        # Track spec_complete but don't send complete yet
                        if chunk.get("type") == "spec_complete":
                            spec_complete_received = True
                            spec_path = chunk.get("path")
                            await websocket.send_json(chunk)
                            continue

                        # When response_done arrives, send complete if spec was done
                        if chunk.get("type") == "response_done":
                            await websocket.send_json(chunk)
                            if spec_complete_received:
                                await websocket.send_json({"type": "complete", "path": spec_path})
                            continue

                        await websocket.send_json(chunk)

                elif msg_type == "message":
                    # User sent a message
                    if not session:
                        session = get_session(project_name)
                        if not session:
                            await websocket.send_json({
                                "type": "error",
                                "content": "No active session. Send 'start' first."
                            })
                            continue

                    user_content = message.get("content", "").strip()

                    # Parse attachments if present
                    attachments: list[ImageAttachment] = []
                    raw_attachments = message.get("attachments", [])
                    if raw_attachments:
                        try:
                            for raw_att in raw_attachments:
                                attachments.append(ImageAttachment(**raw_att))
                        except (ValidationError, Exception) as e:
                            logger.warning(f"Invalid attachment data: {e}")
                            await websocket.send_json({
                                "type": "error",
                                "content": f"Invalid attachment: {str(e)}"
                            })
                            continue

                    # Allow empty content if attachments are present
                    if not user_content and not attachments:
                        await websocket.send_json({
                            "type": "error",
                            "content": "Empty message"
                        })
                        continue

                    # Track spec completion state
                    spec_complete_received = False
                    spec_path = None

                    # Stream Claude's response (with attachments if present)
                    async for chunk in session.send_message(user_content, attachments if attachments else None):
                        # Track spec_complete but don't send complete yet
                        if chunk.get("type") == "spec_complete":
                            spec_complete_received = True
                            spec_path = chunk.get("path")
                            await websocket.send_json(chunk)
                            continue


Good, I have a clear understanding of the WebSocket pattern. Let me create the terminal router now.


Now I'll create the terminal router.


Write

"""
Terminal Router
===============

WebSocket and REST endpoints for interactive Claude terminal sessions.
The terminal provides full system access for resource management and
infrastructure configuration.
"""

import json
import logging
import re
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel

from ..services.terminal_session import (
    TerminalSession,
    get_or_create_terminal_session,
    close_terminal_session,
    get_active_sessions,
)
from ..services.resource_manager import (
    get_manager,
    Resource,
    ResourceType,
    ResourceScope,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/terminal", tags=["terminal"])

# Root directory
ROOT_DIR = Path(__file__).parent.parent.parent

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    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
    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))

# ============================================================================
# REST Endpoints
# ============================================================================

class TerminalSessionStatus(BaseModel):
    """Status of a terminal session."""
    project_name: str
    is_active: bool
    message_count: int
    resource_count: int

class ResourceInfo(BaseModel):
    """Resource information."""
    name: str
    type: str
    scope: str
    host: Optional[str] = None
    url: Optional[str] = None
    verified: bool = False

@router.get("/sessions", response_model=list[str])
async def list_terminal_sessions():
    """List all active terminal sessions."""
    return get_active_sessions()

@router.get("/sessions/{project_name}", response_model=TerminalSessionStatus)
async def get_terminal_status(project_name: str):
    """Get status of a terminal session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    active = get_active_sessions()
    if project_name not in active:
        raise HTTPException(status_code=404, detail="No active terminal session")

    # Get resource count
    project_dir = _get_project_path(project_name)
    manager = get_manager()
    resources = manager.get_merged_resources(project_name, project_dir)

    return TerminalSessionStatus(
        project_name=project_name,
        is_active=True,
        message_count=0,  # Would need to track this in session
        resource_count=len(resources),
    )

@router.delete("/sessions/{project_name}")
async def close_session(project_name: str):
    """Close and remove a terminal session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    await close_terminal_session(project_name)
    return {"success": True, "message": "Session closed"}

@router.get("/resources/{project_name}", response_model=list[ResourceInfo])
async def list_resources(project_name: str):
    """List resources for a project (merged global + project)."""
    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")

    manager = get_manager()
    resources = manager.get_merged_resources(project_name, project_dir)

    return [
        ResourceInfo(
            name=r.name,
            type=r.type.value,
            scope=r.scope.value,
            host=r.host,
            url=r.url,
            verified=r.verified_at is not None,
        )
        for r in resources.values()
    ]

@router.get("/resources/global", response_model=list[ResourceInfo])
async def list_global_resources():
    """List all global resources."""
    manager = get_manager()
    manager._ensure_global_loaded()

    return [
        ResourceInfo(
            name=r.name,
            type=r.type.value,
            scope=r.scope.value,
            host=r.host,
            url=r.url,
            verified=r.verified_at is not None,
        )
        for r in manager._global_resources.values()
    ]

class AddResourceRequest(BaseModel):
    """Request to add a resource."""
    name: str
    type: str  # server, service, database, repository
    scope: str  # global, project
    host: Optional[str] = None
    port: Optional[int] = None
    url: Optional[str] = None
    user: Optional[str] = None
    credential_ref: Optional[str] = None
    description: Optional[str] = None

@router.post("/resources/{project_name}")
async def add_resource(project_name: str, request: AddResourceRequest):
    """Add a resource to a project or globally."""
    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")

    try:
        resource_type = ResourceType(request.type)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid resource type: {request.type}")

    try:
        scope = ResourceScope(request.scope)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid scope: {request.scope}")

    from ..services.resource_manager import ResourceAuth, AuthType

    # Determine auth type based on credential_ref
    auth = ResourceAuth()
    if request.credential_ref:
        # Determine auth type based on resource type
        if resource_type == ResourceType.SERVER:
            auth = ResourceAuth(type=AuthType.SSH_PASSWORD, credential_ref=request.credential_ref)
        elif resource_type == ResourceType.SERVICE:
            auth = ResourceAuth(type=AuthType.API_TOKEN, credential_ref=request.credential_ref)

    resource = Resource(
        name=request.name,
        type=resource_type,
        scope=scope,
        host=request.host,
        port=request.port,
        url=request.url,
        user=request.user,
        auth=auth,
        description=request.description,
    )

    manager = get_manager()
    manager.add_resource(
        resource,
        project_name=project_name if scope == ResourceScope.PROJECT else None,
        project_dir=project_dir if scope == ResourceScope.PROJECT else None,
    )

    return {"success": True, "resource": resource.to_dict()}

@router.delete("/resources/{project_name}/{resource_name}")
async def remove_resource(project_name: str, resource_name: str, scope: str = "project"):
    """Remove a resource."""
    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")

    try:
        resource_scope = ResourceScope(scope)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}")

    manager = get_manager()
    removed = manager.remove_resource(
        resource_name,
        resource_scope,
        project_name=project_name if resource_scope == ResourceScope.PROJECT else None,
        project_dir=project_dir if resource_scope == ResourceScope.PROJECT else None,
    )

    if not removed:
        raise HTTPException(status_code=404, detail="Resource not found")

    return {"success": True, "message": f"Resource '{resource_name}' removed"}

@router.post("/resources/{project_name}/{resource_name}/verify")
async def verify_resource(project_name: str, resource_name: str):
    """Verify a resource is accessible."""
    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")

    manager = get_manager()
    resource = manager.get_resource(resource_name, project_name, project_dir)

    if not resource:
        raise HTTPException(status_code=404, detail="Resource not found")

    result = await manager.verify_resource(resource)

    return {
        "success": result.success,
        "message": result.message,
        "capabilities": result.capabilities,
        "response_time_ms": result.response_time_ms,
    }

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@router.websocket("/ws/{project_name}")
async def terminal_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for interactive terminal chat.

    Message protocol:

    Client -> Server:
    - {"type": "start"} - Start the terminal session
    - {"type": "message", "content": "..."} - Send user message
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "text", "content": "..."} - Text chunk from Claude
    - {"type": "resources", "data": {...}} - Resource list update
    - {"type": "resource_updated", "path": "..."} - Resource file updated
    - {"type": "command_executing", "command": "..."} - Bash command being run
    - {"type": "connection_verified", "result": "..."} - Connection test result
    - {"type": "response_done"} - Response complete
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, 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=4004, reason="Project not found in registry")
        return

    if not project_dir.exists():
        await websocket.close(code=4004, reason="Project directory not found")
        return

    await websocket.accept()

    session: Optional[TerminalSession] = None

    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"})
                    continue

                elif msg_type == "start":
                    # Get or create terminal session
                    session = await get_or_create_terminal_session(
                        project_name,
                        project_dir,
                    )

                    # Stream the initial greeting
                    async for chunk in session.start():
                        await websocket.send_json(chunk)

                elif msg_type == "message":
                    # User sent a message
                    if not session:
                        session = await get_or_create_terminal_session(
                            project_name,
                            project_dir,
                        )

                    user_content = message.get("content", "").strip()

                    if not user_content:
                        await websocket.send_json({
                            "type": "error",
                            "content": "Empty message"
                        })
                        continue

                    # Stream Claude's response
                    async for chunk in session.send_message(user_content):
                        await websocket.send_json(chunk)

                elif msg_type == "get_resources":
                    # Return current resources
                    manager = get_manager()
                    resources = manager.get_merged_resources(project_name, project_dir)
                    await websocket.send_json({
                        "type": "resources",
                        "data": {
                            name: r.to_dict()
                            for name, r in resources.items()
                        }
                    })

                else:
                    await websocket.send_json({
                        "type": "error",
                        "content": f"Unknown message type: {msg_type}"
                    })

            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "content": "Invalid JSON message"
                })
                continue

    except WebSocketDisconnect:
        logger.info(f"Terminal WebSocket disconnected for project: {project_name}")
    except Exception as e:
        logger.exception(f"Terminal WebSocket error for project {project_name}: {e}")
        try:
            await websocket.send_json({
                "type": "error",
                "content": f"Server error: {str(e)}"
            })
        except Exception:
            pass
    finally:
        # Don't close the session on disconnect - allow reconnection
        # Session can be explicitly closed via REST endpoint
        pass


File created successfully at: C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\autocoder-master\server\routers\terminal.py

"""
Terminal Router
===============

WebSocket and REST endpoints for interactive Claude terminal sessions.
The terminal provides full system access for resource management and
infrastructure configuration.
"""

import json
import logging
import re
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel

from ..services.terminal_session import (
    TerminalSession,
    get_or_create_terminal_session,
    close_terminal_session,
    get_active_sessions,
)
from ..services.resource_manager import (
    get_manager,
    Resource,
    ResourceType,
    ResourceScope,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/terminal", tags=["terminal"])

# Root directory
ROOT_DIR = Path(__file__).parent.parent.parent

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    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
    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))

# ============================================================================
# REST Endpoints
# ============================================================================

class TerminalSessionStatus(BaseModel):
    """Status of a terminal session."""
    project_name: str
    is_active: bool
    message_count: int
    resource_count: int

class ResourceInfo(BaseModel):
    """Resource information."""
    name: str
    type: str
    scope: str
    host: Optional[str] = None
    url: Optional[str] = None
    verified: bool = False

@router.get("/sessions", response_model=list[str])
async def list_terminal_sessions():
    """List all active terminal sessions."""
    return get_active_sessions()

@router.get("/sessions/{project_name}", response_model=TerminalSessionStatus)
async def get_terminal_status(project_name: str):
    """Get status of a terminal session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    active = get_active_sessions()
    if project_name not in active:
        raise HTTPException(status_code=404, detail="No active terminal session")

    # Get resource count
    project_dir = _get_project_path(project_name)
    manager = get_manager()
    resources = manager.get_merged_resources(project_name, project_dir)

    return TerminalSessionStatus(
        project_name=project_name,
        is_active=True,
        message_count=0,  # Would need to track this in session
        resource_count=len(resources),
    )

@router.delete("/sessions/{project_name}")
async def close_session(project_name: str):
    """Close and remove a terminal session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    await close_terminal_session(project_name)
    return {"success": True, "message": "Session closed"}

@router.get("/resources/{project_name}", response_model=list[ResourceInfo])
async def list_resources(project_name: str):
    """List resources for a project (merged global + project)."""
    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")

    manager = get_manager()
    resources = manager.get_merged_resources(project_name, project_dir)

    return [
        ResourceInfo(
            name=r.name,
            type=r.type.value,
            scope=r.scope.value,
            host=r.host,
            url=r.url,
            verified=r.verified_at is not None,
        )
        for r in resources.values()
    ]

@router.get("/resources/global", response_model=list[ResourceInfo])
async def list_global_resources():
    """List all global resources."""
    manager = get_manager()
    manager._ensure_global_loaded()

    return [
        ResourceInfo(
            name=r.name,
            type=r.type.value,
            scope=r.scope.value,
            host=r.host,
            url=r.url,
            verified=r.verified_at is not None,
        )
        for r in manager._global_resources.values()
    ]

class AddResourceRequest(BaseModel):
    """Request to add a resource."""
    name: str
    type: str  # server, service, database, repository
    scope: str  # global, project
    host: Optional[str] = None
    port: Optional[int] = None
    url: Optional[str] = None
    user: Optional[str] = None
    credential_ref: Optional[str] = None
    description: Optional[str] = None

@router.post("/resources/{project_name}")
async def add_resource(project_name: str, request: AddResourceRequest):
    """Add a resource to a project or globally."""
    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")

    try:
        resource_type = ResourceType(request.type)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid resource type: {request.type}")

    try:
        scope = ResourceScope(request.scope)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid scope: {request.scope}")

    from ..services.resource_manager import ResourceAuth, AuthType

    # Determine auth type based on credential_ref
    auth = ResourceAuth()
    if request.credential_ref:
        # Determine auth type based on resource type
        if resource_type == ResourceType.SERVER:
            auth = ResourceAuth(type=AuthType.SSH_PASSWORD, credential_ref=request.credential_ref)
        elif resource_type == ResourceType.SERVICE:
            auth = ResourceAuth(type=AuthType.API_TOKEN, credential_ref=request.credential_ref)

    resource = Resource(
        name=request.name,
        type=resource_type,
        scope=scope,
        host=request.host,
        port=request.port,
        url=request.url,
        user=request.user,
        auth=auth,
        description=request.description,
    )

    manager = get_manager()
    manager.add_resource(
        resource,
        project_name=project_name if scope == ResourceScope.PROJECT else None,
        project_dir=project_dir if scope == ResourceScope.PROJECT else None,
    )

    return {"success": True, "resource": resource.to_dict()}

@router.delete("/resources/{project_name}/{resource_name}")
async def remove_resource(project_name: str, resource_name: str, scope: str = "project"):
    """Remove a resource."""
    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")

    try:
        resource_scope = ResourceScope(scope)
    except ValueError:
        raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}")

    manager = get_manager()
    removed = manager.remove_resource(
        resource_name,
        resource_scope,
        project_name=project_name if resource_scope == ResourceScope.PROJECT else None,
        project_dir=project_dir if resource_scope == ResourceScope.PROJECT else None,
    )

    if not removed:
        raise HTTPException(status_code=404, detail="Resource not found")

    return {"success": True, "message": f"Resource '{resource_name}' removed"}

@router.post("/resources/{project_name}/{resource_name}/verify")
async def verify_resource(project_name: str, resource_name: str):
    """Verify a resource is accessible."""
    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")

    manager = get_manager()
    resource = manager.get_resource(resource_name, project_name, project_dir)

    if not resource:
        raise HTTPException(status_code=404, detail="Resource not found")

    result = await manager.verify_resource(resource)

    return {
        "success": result.success,
        "message": result.message,
        "capabilities": result.capabilities,
        "response_time_ms": result.response_time_ms,
    }

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@router.websocket("/ws/{project_name}")
async def terminal_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for interactive terminal chat.

    Message protocol:

    Client -> Server:
    - {"type": "start"} - Start the terminal session
    - {"type": "message", "content": "..."} - Send user message
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "text", "content": "..."} - Text chunk from Claude
    - {"type": "resources", "data": {...}} - Resource list update
    - {"type": "resource_updated", "path": "..."} - Resource file updated
    - {"type": "command_executing", "command": "..."} - Bash command being run
    - {"type": "connection_verified", "result": "..."} - Connection test result
    - {"type": "response_done"} - Response complete
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, 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=4004, reason="Project not found in registry")
        return

    if not project_dir.exists():
        await websocket.close(code=4004, reason="Project directory not found")
        return

    await websocket.accept()

    session: Optional[TerminalSession] = None

    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"})
                    continue

                elif msg_type == "start":
                    # Get or create terminal session
                    session = await get_or_create_terminal_session(
                        project_name,
                        project_dir,
                    )

                    # Stream the initial greeting
                    async for chunk in session.start():
                        await websocket.send_json(chunk)

                elif msg_type == "message":
                    # User sent a message
                    if not session:
                        session = await get_or_create_terminal_session(
                            project_name,
                            project_dir,
                        )

                    user_content = message.get("content", "").strip()

                    if not user_content:
                        await websocket.send_json({
                            "type": "error",
                            "content": "Empty message"
                        })
                        continue

                    # Stream Claude's response
                    async for chunk in session.send_message(user_content):
                        await websocket.send_json(chunk)

                elif msg_type == "get_resources":
                    # Return current resources
                    manager = get_manager()
                    resources = manager.get_merged_resources(project_name, project_dir)
                    await websocket.send_json({
                        "type": "resources",
                        "data": {
                            name: r.to_dict()
                            for name, r in resources.items()
                        }
                    })

                else:
                    await websocket.send_json({
                        "type": "error",
                        "content": f"Unknown message type: {msg_type}"
                    })

            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "content": "Invalid JSON message"
                })
                continue

    except WebSocketDisconnect:
        logger.info(f"Terminal WebSocket disconnected for project: {project_name}")
    except Exception as e:
        logger.exception(f"Terminal WebSocket error for project {project_name}: {e}")
        try:
            await websocket.send_json({
                "type": "error",
                "content": f"Server error: {str(e)}"
            })
        except Exception:
            pass
    finally:
        # Don't close the session on disconnect - allow reconnection
        # Session can be explicitly closed via REST endpoint
        pass


Now I need to:
1. Update the routers __init__.py to export the terminal router
2. Register the router in main.pyLet me do both of those.


Now let me register the terminal router in the routers __init__.py and main.py.


     1→"""
     2→API Routers
     3→===========
     4→
     5→FastAPI routers for different API endpoints.
     6→"""
     7→
     8→from .agent import router as agent_router
     9→from .assistant_chat import router as assistant_chat_router
    10→from .features import router as features_router
    11→from .filesystem import router as filesystem_router
    12→from .projects import router as projects_router
    13→from .spec_creation import router as spec_creation_router
    14→
    15→__all__ = [
    16→    "projects_router",
    17→    "features_router",
    18→    "agent_router",
    19→    "spec_creation_router",
    20→    "filesystem_router",
    21→    "assistant_chat_router",
    22→]
    23→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
API Routers
===========

FastAPI routers for different API endpoints.
"""

from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .spec_creation import router as spec_creation_router

__all__ = [
    "projects_router",
    "features_router",
    "agent_router",
    "spec_creation_router",
    "filesystem_router",
    "assistant_chat_router",
]


     1→"""
     2→FastAPI Main Application
     3→========================
     4→
     5→Main entry point for the Autonomous Coding UI server.
     6→Provides REST API, WebSocket, and static file serving.
     7→"""
     8→
     9→import shutil
    10→from contextlib import asynccontextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import FastAPI, HTTPException, Request, WebSocket
    14→from fastapi.middleware.cors import CORSMiddleware
    15→from fastapi.responses import FileResponse
    16→from fastapi.staticfiles import StaticFiles
    17→
    18→from .routers import (
    19→    agent_router,
    20→    assistant_chat_router,
    21→    features_router,
    22→    filesystem_router,
    23→    projects_router,
    24→    spec_creation_router,
    25→)
    26→from .schemas import SetupStatus
    27→from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
    28→from .services.process_manager import cleanup_all_managers
    29→from .websocket import project_websocket
    30→
    31→# Paths
    32→ROOT_DIR = Path(__file__).parent.parent
    33→UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
    34→
    35→
    36→@asynccontextmanager
    37→async def lifespan(app: FastAPI):
    38→    """Lifespan context manager for startup and shutdown."""
    39→    # Startup
    40→    yield
    41→    # Shutdown - cleanup all running agents and assistant sessions
    42→    await cleanup_all_managers()
    43→    await cleanup_assistant_sessions()
    44→
    45→
    46→# Create FastAPI app
    47→app = FastAPI(
    48→    title="Autonomous Coding UI",
    49→    description="Web UI for the Autonomous Coding Agent",
    50→    version="1.0.0",
    51→    lifespan=lifespan,
    52→)
    53→
    54→# CORS - allow only localhost origins for security
    55→app.add_middleware(
    56→    CORSMiddleware,
    57→    allow_origins=[
    58→        "http://localhost:5173",      # Vite dev server
    59→        "http://127.0.0.1:5173",
    60→        "http://localhost:8888",      # Production
    61→        "http://127.0.0.1:8888",
    62→    ],
    63→    allow_credentials=True,
    64→    allow_methods=["*"],
    65→    allow_headers=["*"],
    66→)
    67→
    68→
    69→# ============================================================================
    70→# Security Middleware
    71→# ============================================================================
    72→
    73→@app.middleware("http")
    74→async def require_localhost(request: Request, call_next):
    75→    """Only allow requests from localhost."""
    76→    client_host = request.client.host if request.client else None
    77→
    78→    # Allow localhost connections
    79→    if client_host not in ("127.0.0.1", "::1", "localhost", None):
    80→        raise HTTPException(status_code=403, detail="Localhost access only")
    81→
    82→    return await call_next(request)
    83→
    84→
    85→# ============================================================================
    86→# Include Routers
    87→# ============================================================================
    88→
    89→app.include_router(projects_router)
    90→app.include_router(features_router)
    91→app.include_router(agent_router)
    92→app.include_router(spec_creation_router)
    93→app.include_router(filesystem_router)
    94→app.include_router(assistant_chat_router)
    95→
    96→
    97→# ============================================================================
    98→# WebSocket Endpoint
    99→# ============================================================================
   100→
   101→@app.websocket("/ws/projects/{project_name}")
   102→async def websocket_endpoint(websocket: WebSocket, project_name: str):
   103→    """WebSocket endpoint for real-time project updates."""
   104→    await project_websocket(websocket, project_name)
   105→
   106→
   107→# ============================================================================
   108→# Setup & Health Endpoints
   109→# ============================================================================
   110→
   111→@app.get("/api/health")
   112→async def health_check():
   113→    """Health check endpoint."""
   114→    return {"status": "healthy"}
   115→
   116→
   117→@app.get("/api/setup/status", response_model=SetupStatus)
   118→async def setup_status():
   119→    """Check system setup status."""
   120→    # Check for Claude CLI
   121→    claude_cli = shutil.which("claude") is not None
   122→
   123→    # Check for credentials file
   124→    credentials_path = Path.home() / ".claude" / ".credentials.json"
   125→    credentials = credentials_path.exists()
   126→
   127→    # Check for Node.js and npm
   128→    node = shutil.which("node") is not None
   129→    npm = shutil.which("npm") is not None
   130→
   131→    return SetupStatus(
   132→        claude_cli=claude_cli,
   133→        credentials=credentials,
   134→        node=node,
   135→        npm=npm,
   136→    )
   137→
   138→
   139→# ============================================================================
   140→# Static File Serving (Production)
   141→# ============================================================================
   142→
   143→# Serve React build files if they exist
   144→if UI_DIST_DIR.exists():
   145→    # Mount static assets
   146→    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
   147→
   148→    @app.get("/")
   149→    async def serve_index():
   150→        """Serve the React app index.html."""
   151→        return FileResponse(UI_DIST_DIR / "index.html")
   152→
   153→    @app.get("/{path:path}")
   154→    async def serve_spa(path: str):
   155→        """
   156→        Serve static files or fall back to index.html for SPA routing.
   157→        """
   158→        # Check if the path is an API route (shouldn't hit this due to router ordering)
   159→        if path.startswith("api/") or path.startswith("ws/"):
   160→            raise HTTPException(status_code=404)
   161→
   162→        # Try to serve the file directly
   163→        file_path = UI_DIST_DIR / path
   164→        if file_path.exists() and file_path.is_file():
   165→            return FileResponse(file_path)
   166→
   167→        # Fall back to index.html for SPA routing
   168→        return FileResponse(UI_DIST_DIR / "index.html")
   169→
   170→
   171→# ============================================================================
   172→# Main Entry Point
   173→# ============================================================================
   174→
   175→if __name__ == "__main__":
   176→    import uvicorn
   177→    uvicorn.run(
   178→        "server.main:app",
   179→        host="127.0.0.1",  # Localhost only for security
   180→        port=8888,
   181→        reload=True,
   182→    )
   183→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_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 .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """WebSocket endpoint for real-time project updates."""
    await project_websocket(websocket, project_name)

# ============================================================================
# Setup & Health Endpoints
# ============================================================================

@app.get("/api/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
    """Check system 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 Node.js and npm
    node = shutil.which("node") is not None
    npm = shutil.which("npm") is not None

    return SetupStatus(
        claude_cli=claude_cli,
        credentials=credentials,
        node=node,
        npm=npm,
    )

# ============================================================================
# Static File Serving (Production)
# ============================================================================

# Serve React build files if they exist
if UI_DIST_DIR.exists():
    # Mount static assets
    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")

    @app.get("/")
    async def serve_index():
        """Serve the React app index.html."""
        return FileResponse(UI_DIST_DIR / "index.html")

    @app.get("/{path:path}")
    async def serve_spa(path: str):
        """
        Serve static files or fall back to index.html for SPA routing.
        """
        # Check if the path is an API route (shouldn't hit this due to router ordering)
        if path.startswith("api/") or path.startswith("ws/"):
            raise HTTPException(status_code=404)

        # Try to serve the file directly
        file_path = UI_DIST_DIR / path
        if file_path.exists() and file_path.is_file():
            return FileResponse(file_path)

        # Fall back to index.html for SPA routing
        return FileResponse(UI_DIST_DIR / "index.html")

# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "server.main:app",
        host="127.0.0.1",  # Localhost only for security
        port=8888,
        reload=True,
    )


Now let me update both files to include the terminal router.


Edit

"""
API Routers
===========

FastAPI routers for different API endpoints.
"""

from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .spec_creation import router as spec_creation_router

__all__ = [
    "projects_router",
    "features_router",
    "agent_router",
    "spec_creation_router",
    "filesystem_router",
    "assistant_chat_router",
]

"""
API Routers
===========

FastAPI routers for different API endpoints.
"""

from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .spec_creation import router as spec_creation_router
from .terminal import router as terminal_router

__all__ = [
    "projects_router",
    "features_router",
    "agent_router",
    "spec_creation_router",
    "filesystem_router",
    "assistant_chat_router",
    "terminal_router",
]


from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
)

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
    terminal_router,
)


The file C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\autocoder-master\server\routers\__init__.py has been updated successfully.

"""
API Routers
===========

FastAPI routers for different API endpoints.
"""

from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .spec_creation import router as spec_creation_router

__all__ = [
    "projects_router",
    "features_router",
    "agent_router",
    "spec_creation_router",
    "filesystem_router",
    "assistant_chat_router",
]

"""
API Routers
===========

FastAPI routers for different API endpoints.
"""

from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .spec_creation import router as spec_creation_router
from .terminal import router as terminal_router

__all__ = [
    "projects_router",
    "features_router",
    "agent_router",
    "spec_creation_router",
    "filesystem_router",
    "assistant_chat_router",
    "terminal_router",
]

+from .terminal import router as terminal_router


The file C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\autocoder-master\server\main.py has been updated successfully.

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
    terminal_router,
)

"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_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 .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """WebSocket endpoint for real-time project updates."""
    await project_websocket(websocket, project_name)

# ============================================================================
# Setup & Health Endpoints
# ============================================================================

@app.get("/api/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
    """Check system 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 Node.js and npm
    node = shutil.which("node") is not None
    npm = shutil.which("npm") is not None

    return SetupStatus(
        claude_cli=claude_cli,
        credentials=credentials,
        node=node,
        npm=npm,
    )

# ============================================================================
# Static File Serving (Production)
# ============================================================================

# Serve React build files if they exist
if UI_DIST_DIR.exists():
    # Mount static assets
    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")

    @app.get("/")
    async def serve_index():
        """Serve the React app index.html."""
        return FileResponse(UI_DIST_DIR / "index.html")

    @app.get("/{path:path}")
    async def serve_spa(path: str):
        """
        Serve static files or fall back to index.html for SPA routing.
        """
        # Check if the path is an API route (shouldn't hit this due to router ordering)
        if path.startswith("api/") or path.startswith("ws/"):
            raise HTTPException(status_code=404)

        # Try to serve the file directly
        file_path = UI_DIST_DIR / path
        if file_path.exists() and file_path.is_file():
            return FileResponse(file_path)

        # Fall back to index.html for SPA routing
        return FileResponse(UI_DIST_DIR / "index.html")

# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "server.main:app",
        host="127.0.0.1",  # Localhost only for security
        port=8888,
        reload=True,
    )


"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_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.process_manager import cleanup_all_managers
from .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """WebSocket endpoint for real-time project updates."""
    await project_websocket(websocket, project_name)

# ============================================================================
# Setup & Health Endpoints
# ============================================================================

@app.get("/api/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
    """Check system 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 Node.js and npm
    node = shutil.which("node") is not None
    npm = shutil.which("npm") is not None

    return SetupStatus(
        claude_cli=claude_cli,
        credentials=credentials,
        node=node,
        npm=npm,
    )

# ============================================================================
# Static File Serving (Production)
# ============================================================================

# Serve React build files if they exist
if UI_DIST_DIR.exists():
    # Mount static assets
    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")

    @app.get("/")
    async def serve_index():
        """Serve the React app index.html."""
        return FileResponse(UI_DIST_DIR / "index.html")

    @app.get("/{path:path}")
    async def serve_spa(path: str):
        """
        Serve static files or fall back to index.html for SPA routing.
        """
        # Check if the path is an API route (shouldn't hit this due to router ordering)
        if path.startswith("api/") or path.startswith("ws/"):
            raise HTTPException(status_code=404)

        # Try to serve the file directly
        file_path = UI_DIST_DIR / path
        if file_path.exists() and file_path.is_file():
            return FileResponse(file_path)

        # Fall back to index.html for SPA routing
        return FileResponse(UI_DIST_DIR / "index.html")

# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "server.main:app",
        host="127.0.0.1",  # Localhost only for security
        port=8888,
        reload=True,
    )


The backend for the terminal is done. Now I need to create the frontend components. Let me update the todo list and start on the frontend hook.


TodoWrite

Phase 1.1: Disable sandbox in client.py

Phase 1.2: Expand allowed commands in security.py

Expanding allowed commands in security.py

Phase 1.3: Create credential_resolver.py service

Phase 1.4: Create resource_manager.py service

Phase 1.5: Add context injection to prompts.py

Phase 2.1: Create terminal_session.py service

Phase 2.2: Create terminal.py router

Phase 2.3: Create useTerminal.ts frontend hook

Phase 2.4: Create TerminalChat.tsx component

Phase 3: Git Agent (operations + agent + UI)

Phase 4: Parallel coding agents

Phase 5: Integration and UI updates


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Phase 1.1: Disable sandbox in client.py

Phase 1.2: Expand allowed commands in security.py

Expanding allowed commands in security.py

Phase 1.3: Create credential_resolver.py service

Phase 1.4: Create resource_manager.py service

Phase 1.5: Add context injection to prompts.py

Phase 2.1: Create terminal_session.py service

Phase 2.2: Create terminal.py router

Phase 2.3: Create useTerminal.ts frontend hook

Phase 2.4: Create TerminalChat.tsx component

Phase 3: Git Agent (operations + agent + UI)

Phase 4: Parallel coding agents

Phase 5: Integration and UI updates

Phase 1.1: Disable sandbox in client.py

Phase 2.2: Create terminal.py router

Phase 4: Parallel coding agents

Phase 5: Integration and UI updates


Now let me check the frontend hooks to see the pattern used for useSpecChat.ts which I can adapt for useTerminal.ts.


Bash

List frontend hooks


total 60
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan  9 10:36 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan  8 13:56 ../
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  8170 Jan  8 08:02 useAssistantChat.ts
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  5811 Jan  8 08:02 useCelebration.ts
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  3512 Jan  8 08:02 useFeatureSound.ts
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  5986 Jan  9 10:31 useProjects.ts
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 14831 Jan  9 10:36 useSpecChat.ts
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  4191 Jan  8 08:02 useWebSocket.ts


Let me read useSpecChat.ts to understand the pattern for WebSocket hooks.


     1→/**
     2→ * Hook for managing spec creation chat WebSocket connection
     3→ */
     4→
     5→import { useState, useCallback, useRef, useEffect } from 'react'
     6→import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
     7→import { getSpecStatus } from '../lib/api'
     8→
     9→type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
    10→
    11→interface UseSpecChatOptions {
    12→  projectName: string
    13→  onComplete?: (specPath: string) => void
    14→  onError?: (error: string) => void
    15→  /** If true, use import-spec skill instead of create-spec */
    16→  isImport?: boolean
    17→  /** Formatted codebase analysis for import mode */
    18→  codebaseAnalysis?: string | null
    19→}
    20→
    21→interface UseSpecChatReturn {
    22→  messages: ChatMessage[]
    23→  isLoading: boolean
    24→  isComplete: boolean
    25→  connectionStatus: ConnectionStatus
    26→  currentQuestions: SpecQuestion[] | null
    27→  currentToolId: string | null
    28→  start: () => void
    29→  sendMessage: (content: string, attachments?: ImageAttachment[]) => void
    30→  sendAnswer: (answers: Record<string, string | string[]>) => void
    31→  disconnect: () => void
    32→}
    33→
    34→function generateId(): string {
    35→  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
    36→}
    37→
    38→export function useSpecChat({
    39→  projectName,
    40→  onComplete,
    41→  onError,
    42→  isImport = false,
    43→  codebaseAnalysis = null,
    44→}: UseSpecChatOptions): UseSpecChatReturn {
    45→  const [messages, setMessages] = useState<ChatMessage[]>([])
    46→  const [isLoading, setIsLoading] = useState(false)
    47→  const [isComplete, setIsComplete] = useState(false)
    48→  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
    49→  const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null)
    50→  const [currentToolId, setCurrentToolId] = useState<string | null>(null)
    51→
    52→  const wsRef = useRef<WebSocket | null>(null)
    53→  const currentAssistantMessageRef = useRef<string | null>(null)
    54→  const reconnectAttempts = useRef(0)
    55→  const maxReconnectAttempts = 3
    56→  const pingIntervalRef = useRef<number | null>(null)
    57→  const reconnectTimeoutRef = useRef<number | null>(null)
    58→  const isCompleteRef = useRef(false)
    59→
    60→  // Keep isCompleteRef in sync with isComplete state
    61→  useEffect(() => {
    62→    isCompleteRef.current = isComplete
    63→  }, [isComplete])
    64→
    65→  // Clean up on unmount
    66→  useEffect(() => {
    67→    return () => {
    68→      if (pingIntervalRef.current) {
    69→        clearInterval(pingIntervalRef.current)
    70→      }
    71→      if (reconnectTimeoutRef.current) {
    72→        clearTimeout(reconnectTimeoutRef.current)
    73→      }
    74→      if (wsRef.current) {
    75→        wsRef.current.close()
    76→      }
    77→    }
    78→  }, [])
    79→
    80→  // Poll status file as fallback completion detection
    81→  // Claude writes .spec_status.json when done with all spec work
    82→  useEffect(() => {
    83→    // Don't poll if already complete
    84→    if (isComplete) return
    85→
    86→    // Start polling after initial delay (let WebSocket try first)
    87→    const startDelay = setTimeout(() => {
    88→      const pollInterval = setInterval(async () => {
    89→        // Stop if already complete
    90→        if (isCompleteRef.current) {
    91→          clearInterval(pollInterval)
    92→          return
    93→        }
    94→
    95→        try {
    96→          const status = await getSpecStatus(projectName)
    97→
    98→          if (status.exists && status.status === 'complete') {
    99→            // Status file indicates completion - set complete state
   100→            setIsComplete(true)
   101→            setIsLoading(false)
   102→
   103→            // Mark any streaming message as done
   104→            setMessages((prev) => {
   105→              const lastMessage = prev[prev.length - 1]
   106→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   107→                return [
   108→                  ...prev.slice(0, -1),
   109→                  { ...lastMessage, isStreaming: false },
   110→                ]
   111→              }
   112→              return prev
   113→            })
   114→
   115→            // Add system message about completion
   116→            setMessages((prev) => [
   117→              ...prev,
   118→              {
   119→                id: generateId(),
   120→                role: 'system',
   121→                content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
   122→                timestamp: new Date(),
   123→              },
   124→            ])
   125→
   126→            clearInterval(pollInterval)
   127→          }
   128→        } catch {
   129→          // Silently ignore polling errors - WebSocket is primary mechanism
   130→        }
   131→      }, 3000) // Poll every 3 seconds
   132→
   133→      // Cleanup interval on unmount
   134→      return () => clearInterval(pollInterval)
   135→    }, 3000) // Start polling after 3 second delay
   136→
   137→    return () => clearTimeout(startDelay)
   138→  }, [projectName, isComplete])
   139→
   140→  const connect = useCallback(() => {
   141→    if (wsRef.current?.readyState === WebSocket.OPEN) {
   142→      return
   143→    }
   144→
   145→    setConnectionStatus('connecting')
   146→
   147→    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
   148→    const host = window.location.host
   149→    const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}`
   150→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

/**
 * Hook for managing spec creation chat WebSocket connection
 */

import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import { getSpecStatus } from '../lib/api'

type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'

interface UseSpecChatOptions {
  projectName: string
  onComplete?: (specPath: string) => void
  onError?: (error: string) => void
  /** If true, use import-spec skill instead of create-spec */
  isImport?: boolean
  /** Formatted codebase analysis for import mode */
  codebaseAnalysis?: string | null
}

interface UseSpecChatReturn {
  messages: ChatMessage[]
  isLoading: boolean
  isComplete: boolean
  connectionStatus: ConnectionStatus
  currentQuestions: SpecQuestion[] | null
  currentToolId: string | null
  start: () => void
  sendMessage: (content: string, attachments?: ImageAttachment[]) => void
  sendAnswer: (answers: Record<string, string | string[]>) => void
  disconnect: () => void
}

function generateId(): string {
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}

export function useSpecChat({
  projectName,
  onComplete,
  onError,
  isImport = false,
  codebaseAnalysis = null,
}: UseSpecChatOptions): UseSpecChatReturn {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [isComplete, setIsComplete] = useState(false)
  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
  const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null)
  const [currentToolId, setCurrentToolId] = useState<string | null>(null)

  const wsRef = useRef<WebSocket | null>(null)
  const currentAssistantMessageRef = useRef<string | null>(null)
  const reconnectAttempts = useRef(0)
  const maxReconnectAttempts = 3
  const pingIntervalRef = useRef<number | null>(null)
  const reconnectTimeoutRef = useRef<number | null>(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()
      }
    }
  }, [])

  // Poll status file as fallback completion detection
  // Claude writes .spec_status.json when done with all spec work
  useEffect(() => {
    // Don't poll if already complete
    if (isComplete) return

    // Start polling after initial delay (let WebSocket try first)
    const startDelay = setTimeout(() => {
      const pollInterval = setInterval(async () => {
        // Stop if already complete
        if (isCompleteRef.current) {
          clearInterval(pollInterval)
          return
        }

        try {
          const status = await getSpecStatus(projectName)

          if (status.exists && status.status === 'complete') {
            // Status file indicates completion - set complete state
            setIsComplete(true)
            setIsLoading(false)

            // Mark any streaming 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
            })

            // Add system message about completion
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
                timestamp: new Date(),
              },
            ])

            clearInterval(pollInterval)
          }
        } catch {
          // Silently ignore polling errors - WebSocket is primary mechanism
        }
      }, 3000) // Poll every 3 seconds

      // Cleanup interval on unmount
      return () => clearInterval(pollInterval)
    }, 3000) // Start polling after 3 second delay

    return () => clearTimeout(startDelay)
  }, [projectName, isComplete])

  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/spec/ws/${encodeURIComponent(projectName)}`


Let me continue reading to see the rest of the pattern.


   150→
   151→    const ws = new WebSocket(wsUrl)
   152→    wsRef.current = ws
   153→
   154→    ws.onopen = () => {
   155→      setConnectionStatus('connected')
   156→      reconnectAttempts.current = 0
   157→
   158→      // Start ping interval to keep connection alive
   159→      pingIntervalRef.current = window.setInterval(() => {
   160→        if (ws.readyState === WebSocket.OPEN) {
   161→          ws.send(JSON.stringify({ type: 'ping' }))
   162→        }
   163→      }, 30000)
   164→    }
   165→
   166→    ws.onclose = () => {
   167→      setConnectionStatus('disconnected')
   168→      if (pingIntervalRef.current) {
   169→        clearInterval(pingIntervalRef.current)
   170→        pingIntervalRef.current = null
   171→      }
   172→
   173→      // Attempt reconnection if not intentionally closed
   174→      if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
   175→        reconnectAttempts.current++
   176→        const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
   177→        reconnectTimeoutRef.current = window.setTimeout(connect, delay)
   178→      }
   179→    }
   180→
   181→    ws.onerror = () => {
   182→      setConnectionStatus('error')
   183→      onError?.('WebSocket connection error')
   184→    }
   185→
   186→    ws.onmessage = (event) => {
   187→      try {
   188→        const data = JSON.parse(event.data) as SpecChatServerMessage
   189→
   190→        switch (data.type) {
   191→          case 'text': {
   192→            // Append text to current assistant message or create new one
   193→            setMessages((prev) => {
   194→              const lastMessage = prev[prev.length - 1]
   195→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   196→                // Append to existing streaming message
   197→                return [
   198→                  ...prev.slice(0, -1),
   199→                  {
   200→                    ...lastMessage,
   201→                    content: lastMessage.content + data.content,
   202→                  },
   203→                ]
   204→              } else {
   205→                // Create new assistant message
   206→                currentAssistantMessageRef.current = generateId()
   207→                return [
   208→                  ...prev,
   209→                  {
   210→                    id: currentAssistantMessageRef.current,
   211→                    role: 'assistant',
   212→                    content: data.content,
   213→                    timestamp: new Date(),
   214→                    isStreaming: true,
   215→                  },
   216→                ]
   217→              }
   218→            })
   219→            break
   220→          }
   221→
   222→          case 'question': {
   223→            // Show structured question UI
   224→            setCurrentQuestions(data.questions)
   225→            setCurrentToolId(data.tool_id || null)
   226→            setIsLoading(false)
   227→
   228→            // Mark current message as done streaming
   229→            setMessages((prev) => {
   230→              const lastMessage = prev[prev.length - 1]
   231→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   232→                return [
   233→                  ...prev.slice(0, -1),
   234→                  {
   235→                    ...lastMessage,
   236→                    isStreaming: false,
   237→                    questions: data.questions,
   238→                  },
   239→                ]
   240→              }
   241→              return prev
   242→            })
   243→            break
   244→          }
   245→
   246→          case 'spec_complete': {
   247→            setIsComplete(true)
   248→            setIsLoading(false)
   249→
   250→            // Mark current message as done
   251→            setMessages((prev) => {
   252→              const lastMessage = prev[prev.length - 1]
   253→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   254→                return [
   255→                  ...prev.slice(0, -1),
   256→                  { ...lastMessage, isStreaming: false },
   257→                ]
   258→              }
   259→              return prev
   260→            })
   261→
   262→            // Add system message about spec completion
   263→            setMessages((prev) => [
   264→              ...prev,
   265→              {
   266→                id: generateId(),
   267→                role: 'system',
   268→                content: `Specification file created: ${data.path}`,
   269→                timestamp: new Date(),
   270→              },
   271→            ])
   272→
   273→            // NOTE: Do NOT auto-call onComplete here!
   274→            // User should click "Continue to Project" button to start the agent.
   275→            // This matches the CLI behavior where user closes the chat manually.
   276→            break
   277→          }
   278→
   279→          case 'file_written': {
   280→            // Optional: notify about other files being written
   281→            setMessages((prev) => [
   282→              ...prev,
   283→              {
   284→                id: generateId(),
   285→                role: 'system',
   286→                content: `File created: ${data.path}`,
   287→                timestamp: new Date(),
   288→              },
   289→            ])
   290→            break
   291→          }
   292→
   293→          case 'complete': {
   294→            setIsComplete(true)
   295→            setIsLoading(false)
   296→
   297→            // Mark current message as done
   298→            setMessages((prev) => {
   299→              const lastMessage = prev[prev.length - 1]

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

    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 SpecChatServerMessage

        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 'question': {
            // Show structured question UI
            setCurrentQuestions(data.questions)
            setCurrentToolId(data.tool_id || null)
            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,
                    questions: data.questions,
                  },
                ]
              }
              return prev
            })
            break
          }

          case 'spec_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
            })

            // Add system message about spec completion
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `Specification file created: ${data.path}`,
                timestamp: new Date(),
              },
            ])

            // NOTE: Do NOT auto-call onComplete here!
            // User should click "Continue to Project" button to start the agent.
            // This matches the CLI behavior where user closes the chat manually.
            break
          }

          case 'file_written': {
            // Optional: notify about other files being written
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `File created: ${data.path}`,
                timestamp: new Date(),
              },
            ])
            break
          }

          case 'complete': {
            setIsComplete(true)
            setIsLoading(false)

            // Mark current message as done
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]


I've seen enough of the pattern. Let me create the useTerminal hook with a similar structure but adapted for terminal functionality.


Now I'll create the useTerminal hook based on this pattern.