"""Purple Lab - Standalone product backend for Loki Mode.

A Replit-like web UI where users input PRDs and watch agents work.
Separate from the dashboard (which monitors existing sessions).
Purple Lab IS the product -- it starts and manages loki sessions.

Runs on port 57375 (dashboard uses 57374).
"""
from __future__ import annotations

import asyncio
import json
import os
import re
import signal
import subprocess
import sys
import time
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Dict, Optional

try:
    import pexpect
    HAS_PEXPECT = True
except ImportError:
    HAS_PEXPECT = False

import logging
import threading

from datetime import datetime
from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.formparsers import MultiPartException, MultiPartParser
from starlette.requests import ClientDisconnect
from starlette.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
import shlex

from pydantic import BaseModel, field_validator

logger = logging.getLogger("purple-lab")

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

HOST = os.environ.get("PURPLE_LAB_HOST", "127.0.0.1")
PORT = int(os.environ.get("PURPLE_LAB_PORT", "57375"))
MAX_WS_CLIENTS = int(os.environ.get("PURPLE_LAB_MAX_WS_CLIENTS", "50"))
MAX_TERMINAL_PTYS = int(os.environ.get("PURPLE_LAB_MAX_TERMINALS", "20"))

# Resolve paths
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
LOKI_CLI = PROJECT_ROOT / "autonomy" / "loki"
DIST_DIR = SCRIPT_DIR / "dist"

# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manage application startup and shutdown lifecycle."""
    # -- Startup --
    try:
        from models import init_db
        db_url = os.environ.get("DATABASE_URL")
        if db_url:
            await init_db(db_url)
            logger.info("Database initialized")
        else:
            logger.info("No DATABASE_URL set -- running in local mode (no auth, file-based storage)")
    except ImportError:
        logger.info("Database models not available -- running in local mode")
    except Exception as exc:
        logger.warning("Database initialization failed: %s -- falling back to local mode", exc)

    yield

    # -- Shutdown --
    # Stop all file watchers
    file_watcher.stop_all()

    # Stop all dev servers
    await dev_server_manager.stop_all()

    # Clean up all terminal PTYs
    for _sid, _pty in list(_terminal_ptys.items()):
        try:
            if _pty.isalive():
                _pty.close(force=True)
        except Exception:
            pass
    _terminal_ptys.clear()
    _terminal_ws_clients.clear()


app = FastAPI(title="Purple Lab", docs_url=None, redoc_url=None, lifespan=lifespan)

_default_cors_origins = [
    f"http://127.0.0.1:{PORT}",
    f"http://localhost:{PORT}",
]
_cors_env = os.environ.get("PURPLE_LAB_CORS_ORIGINS", "")
_cors_origins = (
    [o.strip() for o in _cors_env.split(",") if o.strip()]
    if _cors_env
    else _default_cors_origins
)

if "*" in _cors_origins:
    logger.warning("CORS wildcard '*' detected -- restricting to localhost for security")
    _cors_origins = _default_cors_origins

app.add_middleware(
    CORSMiddleware,
    allow_origins=_cors_origins,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-Requested-With", "Accept"],
)

# ---------------------------------------------------------------------------
# Session state
# ---------------------------------------------------------------------------


class SessionState:
    """Tracks the active loki session."""

    def __init__(self) -> None:
        self.process: Optional[subprocess.Popen] = None
        self.running = False
        self.paused = False
        self.provider = ""
        self.prd_text = ""
        self.project_dir = ""
        self.start_time: float = 0
        self.log_lines: list[str] = []
        self.log_lines_total: int = 0  # absolute count of all lines ever appended
        self.ws_clients: set[WebSocket] = set()
        self._reader_task: Optional[asyncio.Task] = None
        self._lock = asyncio.Lock()
        # Monotonic generation counter: incremented on each new session start so
        # that a stale _read_process_output finally-block from a previous session
        # does not clobber the running flag of the current session.
        self._generation: int = 0

    async def cleanup(self) -> None:
        """Cancel reader task and close process pipes."""
        if self._reader_task and not self._reader_task.done():
            self._reader_task.cancel()
            try:
                await asyncio.wait_for(self._reader_task, timeout=3)
            except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
                pass
        self._reader_task = None

        if self.process:
            try:
                if self.process.stdout:
                    self.process.stdout.close()
            except Exception:
                pass

    def reset(self) -> None:
        self.process = None
        self.running = False
        self.paused = False
        self.provider = ""
        self.prd_text = ""
        self.project_dir = ""
        self.start_time = 0
        self.log_lines = []
        self.log_lines_total = 0
        # NOTE: self._generation is NOT reset -- it must monotonically increase


def _kill_tracked_child_processes() -> None:
    """Kill all tracked child processes and their process groups."""
    tracked = _get_tracked_child_pids()
    if not tracked:
        return

    # SIGTERM to process groups first
    for pid in tracked:
        try:
            pgid = os.getpgid(pid)
            os.killpg(pgid, signal.SIGTERM)
        except (ProcessLookupError, PermissionError, OSError):
            try:
                os.kill(pid, signal.SIGTERM)
            except (ProcessLookupError, PermissionError, OSError):
                pass

    # Wait briefly for graceful shutdown
    time.sleep(2)

    # SIGKILL anything still running
    for pid in tracked:
        try:
            os.kill(pid, 0)  # Check if still alive
            try:
                pgid = os.getpgid(pid)
                os.killpg(pgid, signal.SIGKILL)
            except (ProcessLookupError, PermissionError, OSError):
                os.kill(pid, signal.SIGKILL)
        except (ProcessLookupError, PermissionError, OSError):
            pass  # Already dead

    _clear_tracked_pids()


session = SessionState()

# Terminal PTY instances keyed by session_id
_terminal_ptys: Dict[str, "pexpect.spawn"] = {}

# Track PIDs of sessions started by Purple Lab (not by external loki CLI)
_PURPLE_LAB_PIDS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "child-pids.json"


def _track_child_pid(pid: int) -> None:
    """Record a PID started by Purple Lab so loki web stop can clean it up.

    Uses fcntl.flock for atomic read-modify-write to prevent race conditions.
    """
    import fcntl
    _PURPLE_LAB_PIDS_FILE.parent.mkdir(parents=True, exist_ok=True)
    fd = os.open(str(_PURPLE_LAB_PIDS_FILE), os.O_RDWR | os.O_CREAT)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX)
        f = os.fdopen(fd, "r+")
        try:
            content = f.read()
            pids = json.loads(content) if content.strip() else []
        except (json.JSONDecodeError, ValueError):
            pids = []
        if pid not in pids:
            pids.append(pid)
        f.seek(0)
        f.truncate()
        f.write(json.dumps(pids))
        f.flush()
    except Exception:
        try:
            os.close(fd)
        except OSError:
            pass
        raise
    else:
        f.close()  # also releases lock and closes fd


def _untrack_child_pid(pid: int) -> None:
    """Remove a PID from tracking after it exits.

    Uses fcntl.flock for atomic read-modify-write.
    """
    import fcntl
    if not _PURPLE_LAB_PIDS_FILE.exists():
        return
    try:
        fd = os.open(str(_PURPLE_LAB_PIDS_FILE), os.O_RDWR)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX)
            f = os.fdopen(fd, "r+")
            try:
                content = f.read()
                pids = json.loads(content) if content.strip() else []
            except (json.JSONDecodeError, ValueError):
                pids = []
            pids = [p for p in pids if p != pid]
            f.seek(0)
            f.truncate()
            f.write(json.dumps(pids))
            f.flush()
        except Exception:
            try:
                os.close(fd)
            except OSError:
                pass
            raise
        else:
            f.close()
    except (json.JSONDecodeError, OSError):
        pass


def _get_tracked_child_pids() -> list[int]:
    """Get all PIDs started by Purple Lab."""
    if not _PURPLE_LAB_PIDS_FILE.exists():
        return []
    try:
        return json.loads(_PURPLE_LAB_PIDS_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        return []


def _clear_tracked_pids() -> None:
    """Clear all tracked PIDs."""
    try:
        _PURPLE_LAB_PIDS_FILE.unlink(missing_ok=True)
    except OSError:
        pass

# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------


class StartRequest(BaseModel):
    prd: str
    provider: str = "claude"
    projectDir: Optional[str] = None
    mode: Optional[str] = None  # "quick" for quick mode

    @field_validator("provider")
    @classmethod
    def validate_provider(cls, v: str) -> str:
        # BUG-E2E-006: Validate provider to prevent injection via unknown providers
        allowed = {"claude", "codex", "gemini", "cline", "aider"}
        if v not in allowed:
            raise ValueError(f"Unknown provider '{v}'. Allowed: {', '.join(sorted(allowed))}")
        return v


class QuickStartRequest(BaseModel):
    prompt: str
    provider: str = "claude"

    @field_validator("provider")
    @classmethod
    def validate_provider(cls, v: str) -> str:
        # BUG-E2E-006: Validate provider to prevent injection via unknown providers
        allowed = {"claude", "codex", "gemini", "cline", "aider"}
        if v not in allowed:
            raise ValueError(f"Unknown provider '{v}'. Allowed: {', '.join(sorted(allowed))}")
        return v


class StopResponse(BaseModel):
    stopped: bool
    message: str


_MAX_PRD_BYTES = 1_048_576  # 1 MB


class PlanRequest(BaseModel):
    prd: str
    provider: str = "claude"


class ReportRequest(BaseModel):
    format: str = "markdown"  # "html" | "markdown"


class ProviderSetRequest(BaseModel):
    provider: str


class OnboardRequest(BaseModel):
    path: str


class FileWriteRequest(BaseModel):
    path: str
    content: str = ""


class DirectoryCreateRequest(BaseModel):
    path: str


class FileDeleteRequest(BaseModel):
    path: str


class ChatRequest(BaseModel):
    message: str
    mode: str = "quick"  # "quick" or "standard"
    # BUG-E2E-004: Accept recent chat history so AI has conversation context
    history: Optional[list[dict]] = None  # [{role: "user"|"assistant", content: str}]

    @field_validator("message")
    @classmethod
    def validate_message(cls, v: str) -> str:
        stripped = v.strip()
        if not stripped:
            raise ValueError("Message cannot be empty")
        if len(stripped.encode("utf-8")) > 100_000:  # 100KB max
            raise ValueError("Message exceeds 100KB limit")
        return stripped


class SecretRequest(BaseModel):
    key: str
    value: str


class DevServerStartRequest(BaseModel):
    command: Optional[str] = None

    @field_validator("command")
    @classmethod
    def validate_command(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        dangerous = set(';|`$(){}<>\n\r')
        if any(c in dangerous for c in v):
            raise ValueError("Command contains disallowed shell characters")
        return v.strip()


# ---------------------------------------------------------------------------
# File Watcher (watchdog-based, broadcasts changes via WebSocket)
# ---------------------------------------------------------------------------

try:
    from watchdog.observers import Observer
    from watchdog.events import FileSystemEventHandler, FileSystemEvent
    HAS_WATCHDOG = True
except ImportError:
    HAS_WATCHDOG = False
    Observer = None  # type: ignore[misc,assignment]
    FileSystemEventHandler = object  # type: ignore[misc,assignment]

# Patterns to ignore when watching for file changes
_WATCH_IGNORE_DIRS = {".loki", "node_modules", ".git", "__pycache__", ".next", ".nuxt", "dist", "build", ".cache"}
_WATCH_IGNORE_EXTENSIONS = {".pyc", ".pyo", ".swp", ".swo", ".swn", ".tmp", ".DS_Store"}


class FileChangeHandler(FileSystemEventHandler):  # type: ignore[misc]
    """Collects file system events and broadcasts them after a debounce window."""

    def __init__(self, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop):
        super().__init__()
        self.project_dir = project_dir
        self.broadcast_fn = broadcast_fn
        self.loop = loop
        self._lock = threading.Lock()
        self._pending: list[dict] = []
        self._debounce_handle: Optional[asyncio.TimerHandle] = None

    def _should_ignore(self, path: str) -> bool:
        """Return True if this path should be ignored.

        BUG-INT-004 fix: only check path components relative to the project
        directory, not the full absolute path. Previously, a project stored
        at e.g. /home/user/build/project/ would have all events ignored
        because 'build' appeared in the absolute path parts.
        """
        # Compute relative path from project root
        try:
            rel = os.path.relpath(path, self.project_dir)
        except ValueError:
            rel = path  # different drives on Windows
        parts = Path(rel).parts
        for part in parts:
            if part in _WATCH_IGNORE_DIRS:
                return True
        name = os.path.basename(path)
        if name in _WATCH_IGNORE_EXTENSIONS:
            return True
        _, ext = os.path.splitext(name)
        if ext in _WATCH_IGNORE_EXTENSIONS:
            return True
        return False

    def on_any_event(self, event) -> None:  # type: ignore[override]
        if event.is_directory and event.event_type not in ("created", "deleted", "moved"):
            return
        src = getattr(event, "src_path", "")
        if not src or self._should_ignore(src):
            return

        with self._lock:
            self._pending.append({
                "path": src,
                "event_type": event.event_type,
            })

        # Schedule debounced broadcast on the asyncio event loop
        try:
            self.loop.call_soon_threadsafe(self._schedule_broadcast)
        except RuntimeError:
            pass  # loop closed

    def _schedule_broadcast(self) -> None:
        """Schedule the broadcast after 200ms of quiet."""
        if self._debounce_handle is not None:
            self._debounce_handle.cancel()
        self._debounce_handle = self.loop.call_later(0.2, self._fire_broadcast)

    def _fire_broadcast(self) -> None:
        """Collect pending changes and broadcast them."""
        with self._lock:
            changes = self._pending[:]
            self._pending.clear()
        self._debounce_handle = None

        if not changes:
            return

        # Deduplicate by path, keeping the last event type
        seen: dict[str, str] = {}
        for c in changes:
            seen[c["path"]] = c["event_type"]

        raw_paths = list(seen.keys())
        event_types = [seen[p] for p in raw_paths]

        # Strip project dir prefix to get relative paths for the frontend
        prefix = self.project_dir.rstrip("/") + "/"
        paths = [p[len(prefix):] if p.startswith(prefix) else p for p in raw_paths]

        asyncio.ensure_future(self.broadcast_fn({
            "type": "file_changed",
            "data": {"paths": paths, "event_types": event_types},
        }))


class FileWatcher:
    """Manages watchdog observers for project directories."""

    def __init__(self) -> None:
        self._observers: Dict[str, "Observer"] = {}  # type: ignore[type-arg]

    def start(self, key: str, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop) -> bool:
        """Start watching a project directory. Returns True if started."""
        if not HAS_WATCHDOG:
            logger.info("watchdog not installed -- file watcher disabled")
            return False

        if key in self._observers:
            self.stop(key)

        if not os.path.isdir(project_dir):
            return False

        handler = FileChangeHandler(project_dir, broadcast_fn, loop)
        observer = Observer()
        observer.schedule(handler, project_dir, recursive=True)
        observer.daemon = True
        observer.start()
        self._observers[key] = observer
        logger.info("File watcher started for %s", project_dir)
        return True

    def stop(self, key: str) -> None:
        """Stop watching."""
        observer = self._observers.pop(key, None)
        if observer is not None:
            observer.stop()
            observer.join(timeout=3)
            logger.info("File watcher stopped for key=%s", key)

    def stop_all(self) -> None:
        """Stop all watchers."""
        for key in list(self._observers):
            self.stop(key)


file_watcher = FileWatcher()


# ---------------------------------------------------------------------------
# Dev Server Manager
# ---------------------------------------------------------------------------


class DevServerManager:
    """Manages dev server processes per session."""

    _PORT_PATTERNS = [
        # Vite: "  Local:   http://localhost:5173/"
        re.compile(r"Local:\s+https?://localhost:(\d+)"),
        # Next.js: "  - Local: http://localhost:3000"
        re.compile(r"-\s+Local:\s+https?://localhost:(\d+)"),
        # Django: "Starting development server at http://127.0.0.1:8000/"
        re.compile(r"server\s+at\s+https?://127\.0\.0\.1:(\d+)", re.IGNORECASE),
        # Flask: " * Running on http://127.0.0.1:5000"
        re.compile(r"Running\s+on\s+https?://127\.0\.0\.1:(\d+)"),
        # Go: "Listening on :8080"
        re.compile(r"(?:listening|serving)\s+on\s+:(\d+)", re.IGNORECASE),
        # Spring Boot/Tomcat: "Tomcat started on port(s): 8080"
        re.compile(r"Tomcat\s+started\s+on\s+port\(s\):\s*(\d+)", re.IGNORECASE),
        # Rails: "Listening on http://0.0.0.0:3000"
        re.compile(r"Listening\s+on\s+https?://0\.0\.0\.0:(\d+)", re.IGNORECASE),
        # Laravel: "Server running on [http://127.0.0.1:8000]"
        re.compile(r"Server\s+running\s+on\s+\[https?://127\.0\.0\.1:(\d+)\]", re.IGNORECASE),
        # Phoenix: "Running MyApp.Endpoint with Bandit at http://localhost:4000"
        re.compile(r"Running\s+\S+\.Endpoint\s+.*?https?://localhost:(\d+)", re.IGNORECASE),
        # Generic "listening on port 3000" or "on port 3000"
        re.compile(r"listening\s+on\s+(?:port\s+)?(\d+)", re.IGNORECASE),
        re.compile(r"on\s+port\s+(\d+)", re.IGNORECASE),
        # "listening on port 3000" / "running on port 3000" / "started on port 3000" / "serving on port 3000"
        re.compile(r"(?:listening|running|started|serving)\s+(?:on\s+)?port\s+(\d+)", re.IGNORECASE),
        # Vite ready message: "ready in 300ms -- http://localhost:5173/"
        re.compile(r"ready\s+in\s+\d+m?s.*localhost:(\d+)"),
        # Generic URL patterns (last resort -- broad matches)
        re.compile(r"https?://0\.0\.0\.0:(\d+)"),
        re.compile(r"https?://127\.0\.0\.1:(\d+)"),
        re.compile(r"https?://localhost:(\d+)"),
    ]

    def __init__(self) -> None:
        self.servers: Dict[str, dict] = {}
        self._portless_available: Optional[bool] = None
        self._portless_proxy_started = False

    def _has_portless(self) -> bool:
        """Check if portless CLI is installed (cached)."""
        if self._portless_available is None:
            try:
                subprocess.run(
                    ["portless", "--version"],
                    capture_output=True, timeout=5,
                )
                self._portless_available = True
            except (FileNotFoundError, subprocess.TimeoutExpired):
                self._portless_available = False
        return self._portless_available

    def _portless_app_name(self, session_id: str) -> str:
        """Generate a deterministic short app name from session_id."""
        clean = re.sub(r"[^a-zA-Z0-9]", "", session_id)
        return f"lab-{clean[:6].lower()}"

    async def _ensure_portless_proxy(self) -> bool:
        """Start the portless proxy if not already running.

        Returns True if the proxy is available, False otherwise.
        """
        if self._portless_proxy_started:
            return True
        # Check if port 1355 is already listening
        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(1)
        try:
            s.connect(("127.0.0.1", 1355))
            s.close()
            self._portless_proxy_started = True
            return True
        except (ConnectionRefusedError, OSError):
            s.close()
        # Try to start the proxy
        try:
            subprocess.Popen(
                ["portless", "proxy", "start"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
            )
            # Give it a moment to start (async to avoid blocking the event loop)
            await asyncio.sleep(1)
            self._portless_proxy_started = True
            return True
        except (FileNotFoundError, OSError):
            return False

    async def detect_dev_command(self, project_dir: str) -> Optional[dict]:
        """Detect the dev command from project files."""
        root = Path(project_dir)
        if not root.is_dir():
            return None

        # Docker Compose is the preferred way to run projects (isolated, no port conflicts)
        for compose_file in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
            if (root / compose_file).exists():
                # Check that Docker is actually available
                try:
                    subprocess.run(["docker", "--version"], capture_output=True, timeout=5)
                except (FileNotFoundError, subprocess.TimeoutExpired):
                    break  # Docker not installed -- fall through to other detection
                # Parse compose file to detect exposed port and enumerate services
                port = 3000  # default
                services_info: list = []
                try:
                    import yaml
                    with open(root / compose_file) as f:
                        compose = yaml.safe_load(f)
                    if compose and "services" in compose:
                        for svc_name, svc in compose["services"].items():
                            svc_ports: list = []
                            for p in svc.get("ports", []):
                                p_str = str(p)
                                if ":" in p_str:
                                    parts = p_str.split(":")
                                    try:
                                        host_port = int(parts[-2].split("-")[0])
                                        svc_ports.append(host_port)
                                    except (ValueError, IndexError):
                                        continue
                            services_info.append({
                                "name": svc_name,
                                "ports": svc_ports,
                                "image": svc.get("image"),
                                "has_build": "build" in svc,
                            })
                        # Use smart resolution to pick the user-facing service
                        _primary_name, port = self._resolve_primary_service(services_info)
                        if _primary_name is None:
                            # Fallback: first service with any port
                            for svc_entry in services_info:
                                if svc_entry["ports"]:
                                    port = svc_entry["ports"][0]
                                    break
                except ImportError:
                    # yaml not available -- fall back to regex parsing
                    try:
                        content = (root / compose_file).read_text()
                        port_match = re.search(r'"?(\d+):(\d+)"?', content)
                        if port_match:
                            port = int(port_match.group(1))
                        # Extract service names via regex
                        for m in re.finditer(r'^  (\w[\w-]*):\s*$', content, re.MULTILINE):
                            services_info.append({"name": m.group(1), "ports": []})
                    except Exception:
                        pass
                except Exception:
                    pass
                result_dict: dict = {
                    "command": f"docker compose -f {compose_file} up --build",
                    "expected_port": port,
                    "framework": "docker",
                }
                if services_info:
                    result_dict["services"] = services_info
                return result_dict

        # -- Full-stack project detection (frontend + backend in subdirectories) --
        frontend_dir_names = ["frontend", "client", "web", "app", "ui", "web-app", "webapp"]
        backend_dir_names = ["backend", "server", "api", "service"]

        frontend_dir: Optional[Path] = None
        backend_dir: Optional[Path] = None

        for d in frontend_dir_names:
            candidate = root / d
            if candidate.is_dir():
                # Verify it is actually a frontend (has package.json or index.html)
                if (candidate / "package.json").exists() or (candidate / "index.html").exists():
                    frontend_dir = candidate
                    break

        for d in backend_dir_names:
            candidate = root / d
            if candidate.is_dir():
                has_py = any(candidate.glob("*.py"))
                has_pkg = (candidate / "package.json").exists()
                has_go = (candidate / "go.mod").exists()
                has_requirements = (candidate / "requirements.txt").exists()
                has_cargo = (candidate / "Cargo.toml").exists()
                if has_py or has_pkg or has_go or has_requirements or has_cargo:
                    backend_dir = candidate
                    break

        if frontend_dir and backend_dir:
            # Detect frontend framework and command
            fe_cmd = "npm run dev"
            fe_port = 3000
            fe_framework = "node"
            fe_pkg = frontend_dir / "package.json"
            if fe_pkg.exists():
                try:
                    pkg = json.loads(fe_pkg.read_text(errors="replace"))
                    fe_scripts = pkg.get("scripts", {})
                    fe_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
                    if "next" in fe_deps:
                        fe_framework = "next"
                        fe_port = 3000
                        fe_cmd = "npm run dev"
                    elif "vite" in fe_deps:
                        fe_framework = "vite"
                        fe_port = 5173
                        fe_cmd = "npm run dev"
                    elif "dev" in fe_scripts:
                        fe_cmd = "npm run dev"
                    elif "start" in fe_scripts:
                        fe_cmd = "npm start"
                except Exception:
                    pass
            elif (frontend_dir / "index.html").exists():
                fe_framework = "static"
                fe_cmd = "python3 -m http.server 3000"
                fe_port = 3000

            # Detect backend framework and command
            be_cmd: Optional[str] = None
            be_port = 8000
            be_framework = "unknown"

            if (backend_dir / "manage.py").exists():
                be_cmd = "python manage.py runserver"
                be_port = 8000
                be_framework = "django"
            else:
                for py_entry in ("app.py", "main.py", "server.py"):
                    py_file = backend_dir / py_entry
                    if py_file.exists():
                        try:
                            src = py_file.read_text(errors="replace")[:4096]
                            if "fastapi" in src.lower() or "FastAPI" in src:
                                module = py_entry[:-3]
                                be_cmd = f"uvicorn {module}:app --reload --port 8000"
                                be_port = 8000
                                be_framework = "fastapi"
                                break
                            if "flask" in src.lower() or "Flask" in src:
                                be_cmd = "flask run --port 5000"
                                be_port = 5000
                                be_framework = "flask"
                                break
                        except OSError:
                            pass
                if be_cmd is None and (backend_dir / "package.json").exists():
                    try:
                        be_pkg = json.loads((backend_dir / "package.json").read_text(errors="replace"))
                        be_scripts = be_pkg.get("scripts", {})
                        be_deps = {**be_pkg.get("dependencies", {}), **be_pkg.get("devDependencies", {})}
                        if "express" in be_deps:
                            be_framework = "express"
                        else:
                            be_framework = "node"
                        be_port = 3001
                        if "dev" in be_scripts:
                            be_cmd = "npm run dev"
                        elif "start" in be_scripts:
                            be_cmd = "npm start"
                    except Exception:
                        pass
                if be_cmd is None and (backend_dir / "go.mod").exists():
                    be_cmd = "go run ."
                    be_port = 8080
                    be_framework = "go"
                if be_cmd is None and (backend_dir / "requirements.txt").exists():
                    # Generic Python backend with requirements.txt
                    for py_entry in ("app.py", "main.py", "server.py", "run.py"):
                        if (backend_dir / py_entry).exists():
                            be_cmd = f"python {py_entry}"
                            be_port = 8000
                            be_framework = "python"
                            break

            if be_cmd:
                return {
                    "framework": "full-stack",
                    "command": f"cd {backend_dir.name} && {be_cmd}",
                    "frontend_command": f"cd {frontend_dir.name} && {fe_cmd}",
                    "expected_port": fe_port,
                    "backend_port": be_port,
                    "multi_service": True,
                    "frontend_dir": str(frontend_dir),
                    "backend_dir": str(backend_dir),
                    "frontend_framework": fe_framework,
                    "backend_framework": be_framework,
                }

        pkg_json = root / "package.json"
        if pkg_json.exists():
            try:
                pkg = json.loads(pkg_json.read_text())
                scripts = pkg.get("scripts", {})
                deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
                # Check for Expo/React Native
                if "expo" in deps:
                    return {"command": "npx expo start", "expected_port": 8081, "framework": "expo"}
                if "dev" in scripts:
                    # Check Next.js BEFORE Vite -- Next.js projects may also have vite as a dep
                    if "next" in deps:
                        fw = "next"
                        port = 3000
                    elif "vite" in deps:
                        fw = "vite"
                        port = 5173
                    else:
                        fw = "node"
                        port = 3000
                    return {"command": "npm run dev", "expected_port": port, "framework": fw}
                if "start" in scripts:
                    fw = "next" if "next" in deps else "react" if "react" in deps else "node"
                    return {"command": "npm start", "expected_port": 3000, "framework": fw}
                if "serve" in scripts:
                    return {"command": "npm run serve", "expected_port": 3000, "framework": "node"}
            except (json.JSONDecodeError, OSError):
                pass

        makefile = root / "Makefile"
        if makefile.exists():
            try:
                content = makefile.read_text()
                for target in ("dev", "run", "serve"):
                    if re.search(rf"^{target}\s*:", content, re.MULTILINE):
                        return {"command": f"make {target}", "expected_port": 8000, "framework": "make"}
            except OSError:
                pass

        if (root / "manage.py").exists():
            return {"command": "python manage.py runserver", "expected_port": 8000, "framework": "django"}

        for py_entry in ("app.py", "main.py", "server.py"):
            py_file = root / py_entry
            if py_file.exists():
                try:
                    with open(py_file, "r", errors="replace") as f:
                        src = f.read(4096)
                    if "fastapi" in src.lower() or "FastAPI" in src:
                        module = py_entry[:-3]
                        return {"command": f"uvicorn {module}:app --reload --port 8000",
                                "expected_port": 8000, "framework": "fastapi"}
                    if "flask" in src.lower() or "Flask" in src:
                        return {"command": "flask run --port 5000",
                                "expected_port": 5000, "framework": "flask"}
                except OSError:
                    pass

        if (root / "go.mod").exists():
            return {"command": "go run .", "expected_port": 8080, "framework": "go"}
        if (root / "Cargo.toml").exists():
            return {"command": "cargo run", "expected_port": 8080, "framework": "rust"}

        # Java/Spring Boot -- Maven
        if (root / "pom.xml").exists():
            if (root / "mvnw").exists():
                return {"command": "./mvnw spring-boot:run", "expected_port": 8080, "framework": "spring"}
            return {"command": "mvn spring-boot:run", "expected_port": 8080, "framework": "spring"}

        # Java/Spring Boot -- Gradle
        if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
            if (root / "gradlew").exists():
                return {"command": "./gradlew bootRun", "expected_port": 8080, "framework": "spring"}
            return {"command": "gradle bootRun", "expected_port": 8080, "framework": "spring"}

        # Ruby on Rails
        if (root / "Gemfile").exists() and (root / "config" / "routes.rb").exists():
            return {"command": "bundle exec rails server", "expected_port": 3000, "framework": "rails"}

        # PHP/Laravel
        if (root / "artisan").exists():
            return {"command": "php artisan serve", "expected_port": 8000, "framework": "laravel"}

        # Elixir/Phoenix
        if (root / "mix.exs").exists() and (root / "lib").is_dir():
            return {"command": "mix phx.server", "expected_port": 4000, "framework": "phoenix"}

        # Swift/Vapor
        if (root / "Package.swift").exists():
            return {"command": "swift run", "expected_port": 8080, "framework": "swift"}

        # Static HTML (no framework -- serve with Python)
        if (root / "index.html").exists():
            return {"command": "python3 -m http.server 8000", "expected_port": 8000, "framework": "static"}

        return None

    def _parse_port(self, output: str) -> Optional[int]:
        """Parse port from dev server stdout."""
        for pattern in self._PORT_PATTERNS:
            m = pattern.search(output)
            if m:
                port = int(m.group(1))
                if 1024 <= port <= 65535:
                    return port
        return None

    def _resolve_primary_service(self, services_info: list[dict]) -> tuple[Optional[str], int]:
        """Determine the primary user-facing service from Docker Compose services."""
        frontend_names = {"frontend", "web", "client", "app", "ui", "next", "vite", "nginx"}
        frontend_ports = {3000, 5173, 8080, 4200, 5000, 4000}

        # Priority 1: Service name matches frontend patterns
        for svc in services_info:
            if svc["name"].lower() in frontend_names and svc.get("ports"):
                return svc["name"], svc["ports"][0]

        # Priority 2: Service has a frontend-typical port
        for svc in services_info:
            for p in svc.get("ports", []):
                if p in frontend_ports:
                    return svc["name"], p

        # Priority 3: Custom-built service (has build, no standard image like postgres/redis)
        infra_images = {"postgres", "redis", "mongo", "mysql", "rabbitmq", "memcached", "elasticsearch"}
        for svc in services_info:
            img = (svc.get("image") or "").split(":")[0].split("/")[-1].lower()
            if svc.get("has_build") and img not in infra_images and svc.get("ports"):
                return svc["name"], svc["ports"][0]

        # Fallback: first service with any port
        for svc in services_info:
            if svc.get("ports"):
                return svc["name"], svc["ports"][0]

        return None, 3000

    def _install_pip_deps(self, project_path: Path, build_env: dict) -> None:
        """Install pip dependencies into a project venv (creates one if needed)."""
        if not (project_path / "requirements.txt").exists():
            return
        venv_dir = None
        for venv_name in ("venv", ".venv", "env"):
            candidate = project_path / venv_name
            if candidate.is_dir() and (candidate / "bin" / "pip").exists():
                venv_dir = candidate
                break
        if venv_dir is None:
            try:
                subprocess.run(
                    [sys.executable, "-m", "venv", str(project_path / "venv")],
                    capture_output=True, timeout=60,
                )
                venv_dir = project_path / "venv"
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
                logger.warning("venv creation failed: %s", exc)
        pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
        try:
            subprocess.run(
                [pip_executable, "install", "-r", "requirements.txt"],
                cwd=str(project_path),
                capture_output=True,
                timeout=120,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
            logger.warning("pip install failed: %s", exc)

    async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
        """Start dev server. Auto-detect command if not provided."""
        if session_id in self.servers:
            await self.stop(session_id)

        # Try to detect dev command -- check root first, then subdirectories
        detected = await self.detect_dev_command(project_dir)
        actual_dir = project_dir
        if not detected:
            # Check immediate subdirectories for a project with package.json
            root = Path(project_dir)
            if root.is_dir():
                for subdir in sorted(root.iterdir()):
                    if subdir.is_dir() and not subdir.name.startswith('.'):
                        sub_detected = await self.detect_dev_command(str(subdir))
                        if sub_detected:
                            detected = sub_detected
                            actual_dir = str(subdir)
                            break
        if not command and not detected:
            return {"status": "error", "message": "No dev command detected. Provide one explicitly."}

        cmd_str = command or (detected["command"] if detected else "")
        expected_port = detected["expected_port"] if detected else 3000
        framework = detected["framework"] if detected else "unknown"
        is_multi_service = detected.get("multi_service", False) if detected else False

        build_env = {**os.environ}
        build_env.update(_load_secrets())

        # -- Multi-service full-stack startup --
        if is_multi_service and not command:
            be_dir = detected.get("backend_dir", actual_dir)
            fe_dir = detected.get("frontend_dir", actual_dir)
            be_cmd_str = detected.get("command", "")
            fe_cmd_str = detected.get("frontend_command", "")
            be_port = detected.get("backend_port", 8000)
            fe_port = detected.get("expected_port", 3000)

            # Install deps in both directories
            for svc_dir_str in (be_dir, fe_dir):
                svc_path = Path(svc_dir_str)
                if (svc_path / "package.json").exists() and not (svc_path / "node_modules").exists():
                    try:
                        subprocess.run(["npm", "install"], cwd=svc_dir_str, capture_output=True, timeout=120, env=build_env)
                    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
                        logger.warning("npm install failed in %s: %s", svc_dir_str, exc)
                if (svc_path / "requirements.txt").exists():
                    self._install_pip_deps(svc_path, build_env)

            popen_kwargs = (
                {"start_new_session": True} if sys.platform != "win32"
                else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
            )

            # Start backend process (uses shell=True because command contains 'cd ...')
            try:
                be_proc = subprocess.Popen(
                    be_cmd_str, shell=True,
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
                    text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
                )
            except Exception as e:
                return {"status": "error", "message": f"Failed to start backend: {e}"}
            _track_child_pid(be_proc.pid)

            # Start frontend process
            try:
                fe_proc = subprocess.Popen(
                    fe_cmd_str, shell=True,
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
                    text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
                )
            except Exception as e:
                # Kill backend if frontend fails to start
                try:
                    be_proc.terminate()
                except (ProcessLookupError, PermissionError, OSError):
                    pass
                _untrack_child_pid(be_proc.pid)
                return {"status": "error", "message": f"Failed to start frontend: {e}"}
            _track_child_pid(fe_proc.pid)

            server_info: dict = {
                "process": fe_proc,  # Primary process is frontend (user-facing)
                "backend_process": be_proc,
                "port": None,
                "expected_port": fe_port,
                "backend_port": be_port,
                "command": fe_cmd_str,
                "original_command": cmd_str,
                "framework": framework,
                "status": "starting",
                "pid": fe_proc.pid,
                "backend_pid": be_proc.pid,
                "project_dir": project_dir,
                "output_lines": [],
                "backend_output_lines": [],
                "multi_service": True,
                "frontend_framework": detected.get("frontend_framework", "unknown"),
                "backend_framework": detected.get("backend_framework", "unknown"),
                "frontend_dir": fe_dir,
                "backend_dir": be_dir,
                "use_portless": False,
                "portless_app_name": None,
            }
            self.servers[session_id] = server_info

            asyncio.create_task(self._monitor_output(session_id))
            asyncio.create_task(self._monitor_backend_output(session_id))
            asyncio.create_task(self._continuous_log_monitor(session_id))

            # Wait for either frontend or backend port (up to 30s)
            for _ in range(60):
                await asyncio.sleep(0.5)
                info = self.servers.get(session_id)
                if not info:
                    return {"status": "error", "message": "Server entry disappeared"}
                if info["status"] == "error":
                    return {
                        "status": "error",
                        "message": "Dev server crashed",
                        "output": info["output_lines"][-10:] if info["output_lines"] else [],
                    }
                if info["port"] is not None:
                    health_ok = await self._health_check(info["port"])
                    if health_ok:
                        info["status"] = "running"
                        services = [
                            {
                                "name": "frontend",
                                "framework": info.get("frontend_framework", "unknown"),
                                "port": fe_port,
                                "status": "running",
                            },
                            {
                                "name": "backend",
                                "framework": info.get("backend_framework", "unknown"),
                                "port": be_port,
                                "status": "running" if be_proc.poll() is None else "error",
                            },
                        ]
                        return {
                            "status": "running",
                            "port": info["port"],
                            "command": fe_cmd_str,
                            "pid": fe_proc.pid,
                            "url": f"/proxy/{session_id}/",
                            "multi_service": True,
                            "framework": "full-stack",
                            "services": services,
                        }

            # Timeout -- report whatever state we have
            if fe_proc.poll() is not None and be_proc.poll() is not None:
                server_info["status"] = "error"
                return {
                    "status": "error",
                    "message": "Both frontend and backend exited before port was detected",
                    "output": server_info["output_lines"][-10:],
                }

            # Fallback to expected port
            health_ok = await self._health_check(fe_port)
            if health_ok:
                server_info["port"] = fe_port
                server_info["status"] = "running"
                return {
                    "status": "running",
                    "port": fe_port,
                    "command": fe_cmd_str,
                    "pid": fe_proc.pid,
                    "url": f"/proxy/{session_id}/",
                    "multi_service": True,
                    "framework": "full-stack",
                }

            server_info["status"] = "starting"
            server_info["port"] = fe_port
            return {
                "status": "starting",
                "message": "Server started but port not yet confirmed",
                "port": fe_port,
                "command": fe_cmd_str,
                "pid": fe_proc.pid,
                "url": f"/proxy/{session_id}/",
                "multi_service": True,
                "framework": "full-stack",
            }

        # -- Single-service startup (original path) --

        # Auto-install dependencies before starting the dev server
        actual_path = Path(actual_dir)
        needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
        needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()

        if needs_npm:
            try:
                subprocess.run(
                    ["npm", "install"],
                    cwd=actual_dir,
                    capture_output=True,
                    timeout=120,
                    env=build_env,
                )
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
                logger.warning("npm install failed: %s", exc)

        if needs_pip:
            self._install_pip_deps(actual_path, build_env)

        # Check if portless is available and proxy is running
        use_portless = False
        portless_app_name = None
        if self._has_portless() and await self._ensure_portless_proxy():
            portless_app_name = self._portless_app_name(session_id)
            use_portless = True

        # Build command as list (no shell=True needed)
        if use_portless and portless_app_name:
            cmd_parts = ["portless", portless_app_name] + shlex.split(cmd_str)
        else:
            cmd_parts = shlex.split(cmd_str)

        try:
            proc = subprocess.Popen(
                cmd_parts,
                shell=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=actual_dir,
                env=build_env,
                **({"start_new_session": True} if sys.platform != "win32"
                   else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
            )
        except Exception as e:
            return {"status": "error", "message": f"Failed to start: {e}"}

        _track_child_pid(proc.pid)

        effective_cmd = " ".join(cmd_parts)
        server_info = {
            "process": proc,
            "port": None,
            "expected_port": expected_port,
            "command": effective_cmd,
            "original_command": cmd_str,
            "framework": framework,
            "status": "starting",
            "pid": proc.pid,
            "project_dir": project_dir,
            "output_lines": [],
            "use_portless": use_portless,
            "portless_app_name": portless_app_name,
        }
        self.servers[session_id] = server_info

        asyncio.create_task(self._monitor_output(session_id))
        asyncio.create_task(self._continuous_log_monitor(session_id))

        # For Docker projects, also start the service health monitor
        if framework == "docker":
            asyncio.create_task(self._monitor_docker_services(session_id))

        # Wait for port detection (up to 30s)
        for _ in range(60):
            await asyncio.sleep(0.5)
            info = self.servers.get(session_id)
            if not info:
                return {"status": "error", "message": "Server entry disappeared"}
            if info["status"] == "error":
                return {
                    "status": "error",
                    "message": "Dev server crashed",
                    "output": info["output_lines"][-10:] if info["output_lines"] else [],
                }
            if info["port"] is not None:
                # For portless, also verify the portless proxy can reach the app
                check_port = info["port"]
                health_ok = await self._health_check(check_port)
                if health_ok:
                    info["status"] = "running"
                    result = {
                        "status": "running",
                        "port": info["port"],
                        "command": effective_cmd,
                        "pid": proc.pid,
                        "url": f"/proxy/{session_id}/",
                    }
                    if use_portless and portless_app_name:
                        result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
                        result["port"] = 1355
                    return result

        if proc.poll() is not None:
            server_info["status"] = "error"
            return {
                "status": "error",
                "message": "Dev server exited before port was detected",
                "output": server_info["output_lines"][-10:],
            }

        health_ok = await self._health_check(expected_port)
        if health_ok:
            server_info["port"] = expected_port
            server_info["status"] = "running"
            result = {
                "status": "running",
                "port": expected_port,
                "command": effective_cmd,
                "pid": proc.pid,
                "url": f"/proxy/{session_id}/",
            }
            if use_portless and portless_app_name:
                result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
                result["port"] = 1355
            return result

        server_info["status"] = "starting"
        server_info["port"] = expected_port
        result = {
            "status": "starting",
            "message": "Server started but port not yet confirmed",
            "port": expected_port,
            "command": effective_cmd,
            "pid": proc.pid,
            "url": f"/proxy/{session_id}/",
        }
        if use_portless and portless_app_name:
            result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
            result["port"] = 1355
        return result

    async def _monitor_output(self, session_id: str) -> None:
        """Background task: read dev server stdout and detect port."""
        info = self.servers.get(session_id)
        if not info:
            return
        proc = info["process"]
        loop = asyncio.get_running_loop()
        try:
            while proc.poll() is None:
                line = await loop.run_in_executor(None, proc.stdout.readline)
                if not line:
                    break
                text = line.rstrip("\n")
                info["output_lines"].append(text)
                if len(info["output_lines"]) > 200:
                    info["output_lines"] = info["output_lines"][-200:]
                detected_port = self._parse_port(text)
                if detected_port:
                    info["port"] = detected_port
                    # Transition from "starting" to "running" when port is detected
                    if info.get("status") == "starting":
                        info["status"] = "running"
        except Exception:
            logger.error("Dev server monitor failed for session %s", session_id, exc_info=True)
        finally:
            # Process exited -- mark as error if it was still starting or running
            if info.get("status") in ("starting", "running"):
                info["status"] = "error"
                # Auto-fix with exponential backoff and circuit breaker
                attempts = info.get("auto_fix_attempts", 0)
                now = time.time()
                timestamps = info.get("auto_fix_timestamps", [])
                recent = [t for t in timestamps if now - t < 300]

                if len(recent) >= 3:
                    info["auto_fix_status"] = "circuit breaker open (3 failures in 5 min)"
                    logger.warning("Auto-fix circuit breaker open for session %s", session_id)
                elif attempts < 3:
                    # BUG-V63-009: Prevent dual monitors from racing to auto-fix
                    if not info.get("_auto_fixing"):
                        info["_auto_fixing"] = True
                        info["auto_fix_attempts"] = attempts + 1
                        timestamps.append(now)
                        info["auto_fix_timestamps"] = timestamps
                        backoff_seconds = 5 * (3 ** attempts)
                        error_context = "\n".join(info.get("output_lines", [])[-30:])

                        async def _delayed_auto_fix():
                            try:
                                await asyncio.sleep(backoff_seconds)
                                await self._auto_fix(session_id, error_context)
                            finally:
                                _info = self.servers.get(session_id)
                                if _info:
                                    _info["_auto_fixing"] = False

                        try:
                            task = asyncio.ensure_future(_delayed_auto_fix())
                            info["_auto_fix_task"] = task
                        except Exception:
                            info["_auto_fixing"] = False
                            logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)

    async def _monitor_backend_output(self, session_id: str) -> None:
        """Background task: read backend dev server stdout for multi-service setups."""
        info = self.servers.get(session_id)
        if not info or not info.get("multi_service"):
            return
        be_proc = info.get("backend_process")
        if not be_proc or not be_proc.stdout:
            return
        loop = asyncio.get_running_loop()
        try:
            while be_proc.poll() is None:
                line = await loop.run_in_executor(None, be_proc.stdout.readline)
                if not line:
                    break
                text = line.rstrip("\n")
                be_lines = info.get("backend_output_lines", [])
                be_lines.append(text)
                if len(be_lines) > 200:
                    be_lines = be_lines[-200:]
                info["backend_output_lines"] = be_lines
                # Also detect backend port from output
                detected_port = self._parse_port(text)
                if detected_port:
                    info["backend_port"] = detected_port
        except Exception:
            logger.error("Backend monitor failed for session %s", session_id, exc_info=True)
        finally:
            if be_proc.poll() is not None and info.get("status") in ("starting", "running"):
                # Only mark error if frontend is also dead
                fe_proc = info.get("process")
                if fe_proc and fe_proc.poll() is not None:
                    info["status"] = "error"

    async def _continuous_log_monitor(self, session_id: str) -> None:
        """Continuously monitor dev server output for errors and auto-fix.

        This runs alongside _monitor_output and watches for error patterns
        in the accumulated log lines. When errors are detected while the
        process is still running (not yet crashed), it triggers the AI
        provider to diagnose and fix the issue proactively.
        """
        info = self.servers.get(session_id)
        if not info:
            return

        last_error_check: float = 0
        error_cooldown = 30  # Don't check more than every 30 seconds

        while True:
            info = self.servers.get(session_id)
            if not info:
                break

            await asyncio.sleep(5)

            now = time.time()
            if now - last_error_check < error_cooldown:
                continue

            # Check recent output for error indicators
            recent_lines = info.get("output_lines", [])[-30:]
            if not recent_lines:
                continue

            recent_text = "\n".join(recent_lines)

            # Look for error indicators in the output
            error_indicators = [
                "Error:", "ERROR", "FATAL", "error:", "failed",
                "TypeError:", "SyntaxError:", "ModuleNotFoundError:",
                "Cannot find module", "ENOENT", "EACCES", "EADDRINUSE",
                "Connection refused", "Build failed", "Compilation failed",
                "npm ERR!", "pip install failed",
            ]

            has_error = any(indicator in recent_text for indicator in error_indicators)

            # Also check if the process exited
            proc = info.get("process")
            process_dead = proc and proc.poll() is not None

            if not has_error and not process_dead:
                continue

            last_error_check = now

            # Don't fix if already fixing (either from this monitor or _auto_fix)
            if info.get("_auto_fixing"):
                continue

            # Don't fix if the _monitor_output auto-fix already handled it
            # (process exited = _monitor_output triggers _auto_fix, skip here)
            if process_dead:
                continue

            project_dir = info.get("project_dir", ".")

            logger.info("Error detected in dev server output for session %s, triggering AI fix", session_id)

            # Broadcast to frontend that we are auto-fixing
            try:
                await _broadcast({
                    "type": "auto_fix",
                    "data": {
                        "session_id": session_id,
                        "status": "detecting",
                        "message": "Error detected in dev server output. AI is analyzing...",
                    }
                })
            except Exception:
                pass

            # Gather context and trigger fix
            info["_auto_fixing"] = True
            try:
                # Get Docker context if applicable
                docker_ctx: dict = {}
                if info.get("framework") == "docker":
                    try:
                        docker_ctx = await _gather_docker_context(Path(project_dir))
                    except Exception:
                        pass

                # Build AI prompt with full context
                compose_content = ""
                compose_file = Path(project_dir) / "docker-compose.yml"
                if not compose_file.exists():
                    compose_file = Path(project_dir) / "docker-compose.yaml"
                if compose_file.exists():
                    try:
                        compose_content = compose_file.read_text(errors="replace")[:5000]
                    except OSError:
                        pass

                error_lines = "\n".join(info.get("output_lines", [])[-50:])

                fix_prompt = (
                    "The dev server has errors. Analyze and fix them.\n\n"
                    f"DEV SERVER OUTPUT (last 50 lines):\n{error_lines[:3000]}\n"
                )
                if docker_ctx:
                    svc_status = docker_ctx.get("service_status", [])
                    if svc_status:
                        fix_prompt += f"\nDOCKER SERVICE STATUS: {json.dumps(svc_status)}\n"
                if compose_content:
                    fix_prompt += f"\nDOCKER-COMPOSE.YML:\n{compose_content}\n"
                fix_prompt += (
                    f"\nPROJECT DIRECTORY: {project_dir}\n\n"
                    "INSTRUCTIONS:\n"
                    "1. Analyze the error in the output above\n"
                    "2. Fix the root cause (edit code, config, Dockerfile, etc.)\n"
                    "3. The system will automatically restart/rebuild after your fix\n"
                    "4. Make the fix work on any platform"
                )

                # Trigger AI fix
                loki = _find_loki_cli()
                if loki:
                    fix_env = {**os.environ, **_load_secrets()}
                    fix_env["LOKI_MAX_ITERATIONS"] = "5"
                    fix_env["LOKI_AUTO_FIX"] = "true"
                    # Pass provider via env
                    _mf_prov = Path(project_dir) / ".loki" / "state" / "provider"
                    if _mf_prov.exists():
                        try:
                            _mfp = _mf_prov.read_text().strip()
                            if _mfp:
                                fix_env["LOKI_PROVIDER"] = _mfp
                        except OSError:
                            pass

                    await asyncio.to_thread(
                        subprocess.run,
                        [loki, "quick", fix_prompt],
                        capture_output=True, text=True, cwd=project_dir, timeout=300,
                        env=fix_env,
                    )

                    # Rebuild if Docker
                    if info.get("framework") == "docker":
                        await asyncio.to_thread(
                            subprocess.run,
                            ["docker", "compose", "up", "-d", "--build", "--no-deps"],
                            capture_output=True, cwd=project_dir, timeout=120,
                        )

                    # Broadcast fix complete
                    try:
                        await _broadcast({
                            "type": "auto_fix",
                            "data": {
                                "session_id": session_id,
                                "status": "completed",
                                "message": "AI fix applied. Rebuilding...",
                            }
                        })
                    except Exception:
                        pass
            except Exception as e:
                logger.error("Continuous log monitor fix failed: %s", e)
            finally:
                # Re-fetch info in case it was replaced during the fix
                info = self.servers.get(session_id)
                if info:
                    info["_auto_fixing"] = False

    async def _auto_fix(self, session_id: str, error_context: str) -> None:
        """Auto-fix a crashed dev server by invoking loki quick with the error."""
        info = self.servers.get(session_id)
        if not info:
            return

        attempt = info.get("auto_fix_attempts", 1)
        # Preserve auto_fix_attempts across stop/start to maintain circuit breaker
        saved_attempts = info.get("auto_fix_attempts", 0)
        info["auto_fix_status"] = f"fixing (attempt {attempt}/3)"
        logger.info("Auto-fix attempt %d/3 for session %s", attempt, session_id)

        # Find the project directory for this session
        target = _find_session_dir(session_id)
        if target is None:
            info["auto_fix_status"] = "failed (session not found)"
            return

        loki = _find_loki_cli()
        if loki is None:
            info["auto_fix_status"] = "failed (loki CLI not found)"
            return

        fix_message = (
            f"The dev server crashed. Fix the error and ensure the app starts correctly.\n\n"
            f"DEV SERVER ERROR OUTPUT:\n{error_context}"
        )

        # Enrich with Docker context if applicable
        if info.get("framework") == "docker":
            try:
                docker_ctx = await _gather_docker_context(target)
                if docker_ctx.get("failing_services"):
                    fix_message += "\n\nDOCKER SERVICE STATUS:\n" + json.dumps(docker_ctx["service_status"], indent=2)
                    for svc_name, svc_logs in docker_ctx.get("service_logs", {}).items():
                        if svc_name != "_combined":
                            fix_message += f"\n\nFAILING SERVICE '{svc_name}' LOGS:\n{svc_logs}"
                    diagnoses = _diagnose_errors("\n".join(docker_ctx.get("service_logs", {}).values()))
                    if diagnoses:
                        fix_message += "\n\nAUTO-DIAGNOSIS:\n" + "\n".join(
                            f"- {d['diagnosis']}: {d['suggestion']}" for d in diagnoses)
                if docker_ctx.get("project_structure"):
                    fix_message += "\n\nPROJECT FILES:\n" + docker_ctx["project_structure"]
            except Exception:
                logger.debug("Docker context gathering for auto-fix failed", exc_info=True)

        # Save original command, framework, and multi_service flag before stop() removes the info dict
        cmd = info.get("original_command")
        is_multi_service = info.get("multi_service", False)
        framework = info.get("framework")

        try:
            auto_fix_env = {**os.environ}
            auto_fix_env.update(_load_secrets())
            # Pass provider via env for auto-fix commands
            _af_prov_file = target / ".loki" / "state" / "provider"
            if _af_prov_file.exists():
                try:
                    _afp = _af_prov_file.read_text().strip()
                    if _afp:
                        auto_fix_env["LOKI_PROVIDER"] = _afp
                except OSError:
                    pass
            result = await asyncio.get_running_loop().run_in_executor(
                None,
                lambda: subprocess.run(
                    [loki, "quick", fix_message],
                    cwd=str(target),
                    capture_output=True,
                    text=True,
                    timeout=300,
                    env=auto_fix_env,
                    start_new_session=True,
                ),
            )
            if result.returncode == 0:
                logger.info("Auto-fix succeeded for session %s, restarting dev server", session_id)
                # For Docker projects, rebuild images before restarting
                # (fix may have changed Dockerfile, package.json, requirements.txt, etc.)
                if framework == "docker":
                    await asyncio.to_thread(
                        subprocess.run,
                        ["docker", "compose", "up", "-d", "--build", "--no-deps"],
                        capture_output=True, cwd=str(target), timeout=120
                    )
                # Restart the dev server
                await self.stop(session_id)
                await asyncio.sleep(1)
                # BUG-V63-008: For multi-service sessions, omit command= to re-enter
                # the multi-service detection path in start()
                if is_multi_service:
                    await self.start(session_id, str(target))
                else:
                    await self.start(session_id, str(target), command=cmd)
                # Transfer circuit breaker state to the new info dict
                new_info = self.servers.get(session_id)
                if new_info:
                    new_info["auto_fix_attempts"] = saved_attempts
                    new_info["auto_fix_status"] = "fixed, restarting..."
            else:
                # info may be stale after stop, but we only read from it here
                info["auto_fix_status"] = f"fix attempt {attempt} failed"
                logger.warning("Auto-fix attempt %d failed for session %s", attempt, session_id)
        except Exception as e:
            info["auto_fix_status"] = f"fix error: {str(e)[:100]}"
            logger.error("Auto-fix error for session %s: %s", session_id, e)

    async def _health_check(self, port: int, retries: int = 3) -> bool:
        """Check if a port is responding to TCP connections."""
        import socket
        loop = asyncio.get_running_loop()
        for _ in range(retries):
            try:
                def check() -> bool:
                    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                    s.settimeout(1)
                    try:
                        s.connect(("127.0.0.1", port))
                        return True
                    except (ConnectionRefusedError, OSError):
                        return False
                    finally:
                        s.close()
                if await loop.run_in_executor(None, check):
                    return True
            except Exception:
                pass
            await asyncio.sleep(0.5)
        return False

    async def stop(self, session_id: str) -> dict:
        """Stop dev server for session."""
        info = self.servers.pop(session_id, None)
        if not info:
            return {"stopped": False, "message": "No dev server running"}

        # Cancel any pending auto-fix task
        fix_task = info.get("_auto_fix_task")
        if fix_task and not fix_task.done():
            fix_task.cancel()

        # Cancel any tracked Docker service fix tasks (BUG-V64-003)
        for task in info.get("_fix_tasks", {}).values():
            if not task.done():
                task.cancel()

        # For Docker containers, run docker compose down
        if info.get("framework") == "docker":
            try:
                project_dir = info.get("project_dir", ".")
                # Find the compose file used in the original command
                compose_cmd = info.get("original_command", "")
                compose_args = ["docker", "compose", "down", "--remove-orphans"]
                # Extract -f flag from original command if present
                f_match = re.search(r'-f\s+(\S+)', compose_cmd)
                if f_match:
                    compose_args = ["docker", "compose", "-f", f_match.group(1), "down", "--remove-orphans"]
                subprocess.run(
                    compose_args,
                    cwd=project_dir,
                    capture_output=True, timeout=30,
                )
            except (ProcessLookupError, PermissionError, OSError):
                pass

        proc = info["process"]
        if proc.poll() is None:
            if sys.platform != "win32":
                try:
                    pgid = os.getpgid(proc.pid)
                    os.killpg(pgid, signal.SIGTERM)
                except (ProcessLookupError, PermissionError, OSError):
                    try:
                        proc.terminate()
                    except (ProcessLookupError, PermissionError, OSError):
                        pass
            else:
                try:
                    proc.terminate()
                except (ProcessLookupError, PermissionError, OSError):
                    pass
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                if sys.platform != "win32":
                    try:
                        pgid = os.getpgid(proc.pid)
                        os.killpg(pgid, signal.SIGKILL)
                    except (ProcessLookupError, PermissionError, OSError):
                        try:
                            proc.kill()
                        except (ProcessLookupError, PermissionError, OSError):
                            pass
                else:
                    try:
                        proc.kill()
                    except (ProcessLookupError, PermissionError, OSError):
                        pass

        _untrack_child_pid(proc.pid)

        # For multi-service setups, also kill the backend process
        be_proc = info.get("backend_process")
        if be_proc:
            if be_proc.poll() is None:
                if sys.platform != "win32":
                    try:
                        pgid = os.getpgid(be_proc.pid)
                        os.killpg(pgid, signal.SIGTERM)
                    except (ProcessLookupError, PermissionError, OSError):
                        try:
                            be_proc.terminate()
                        except (ProcessLookupError, PermissionError, OSError):
                            pass
                else:
                    try:
                        be_proc.terminate()
                    except (ProcessLookupError, PermissionError, OSError):
                        pass
                try:
                    be_proc.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    try:
                        be_proc.kill()
                    except (ProcessLookupError, PermissionError, OSError):
                        pass
            _untrack_child_pid(be_proc.pid)

        return {"stopped": True, "message": "Dev server stopped"}

    async def status(self, session_id: str) -> dict:
        """Get dev server status."""
        info = self.servers.get(session_id)
        if not info:
            return {
                "running": False,
                "status": "stopped",
                "port": None,
                "command": None,
                "pid": None,
                "url": None,
                "framework": None,
                "output": [],
            }

        proc = info["process"]
        alive = proc.poll() is None
        if not alive and info["status"] in ("running", "starting"):
            info["status"] = "error"

        result = {
            "running": alive and info["status"] == "running",
            "status": info["status"],
            "port": info.get("port"),
            "command": info.get("command"),
            "pid": proc.pid if alive else None,
            "url": f"/proxy/{session_id}/" if info.get("port") and alive else None,
            "framework": info.get("framework"),
            "output": info.get("output_lines", [])[-20:],
            "auto_fix_status": info.get("auto_fix_status"),
            "auto_fix_attempts": info.get("auto_fix_attempts", 0),
        }

        # Multi-service status reporting
        if info.get("multi_service"):
            be_proc = info.get("backend_process")
            be_alive = be_proc.poll() is None if be_proc else False
            result["multi_service"] = True
            result["framework"] = "full-stack"
            result["services"] = [
                {
                    "name": "frontend",
                    "framework": info.get("frontend_framework", "unknown"),
                    "port": info.get("expected_port"),
                    "status": "running" if alive else "error",
                },
                {
                    "name": "backend",
                    "framework": info.get("backend_framework", "unknown"),
                    "port": info.get("backend_port"),
                    "status": "running" if be_alive else "error",
                },
            ]
            result["backend_output"] = info.get("backend_output_lines", [])[-20:]

        if info.get("use_portless") and info.get("portless_app_name"):
            app_name = info["portless_app_name"]
            result["portless_url"] = f"http://{app_name}.localhost:1355/"
            if alive:
                result["port"] = 1355
        return result

    async def _monitor_docker_services(self, session_id: str) -> None:
        """Background loop: poll Docker Compose services, auto-fix failures."""
        info = self.servers.get(session_id)
        if not info:
            return

        project_dir = Path(info.get("project_dir", "."))
        info["docker_service_health"] = {}

        while True:
            info = self.servers.get(session_id)
            if not info or not info.get("process") or info["process"].poll() is not None:
                break
            await asyncio.sleep(10)
            # Re-check after sleep in case server was stopped
            info = self.servers.get(session_id)
            if not info or not info.get("process") or info["process"].poll() is not None:
                break

            try:
                docker_ctx = await _gather_docker_context(project_dir)
            except Exception:
                continue

            # Update service health
            for svc in docker_ctx.get("service_status", []):
                name = svc["name"]
                prev = info["docker_service_health"].get(name, {})
                svc_health: dict = {
                    "name": name,
                    "status": svc["state"],
                    "exit_code": svc.get("exit_code", 0),
                    "restarts": prev.get("restarts", 0),
                    "fix_attempts": prev.get("fix_attempts", 0),
                    "fix_timestamps": prev.get("fix_timestamps", []),
                }

                # Detect new failure
                was_running = prev.get("status") in ("running", None)
                now_failed = svc["state"] in ("exited", "dead")

                # Detect services that need fixing:
                # 1. was_running and now_failed: service transitioned from running to exited
                # 2. Persistently failed: service is exited AND has never been successfully fixed
                #    (fix_attempts == 0 or fix_status != "fixed")
                persistently_failed = (
                    now_failed
                    and prev.get("fix_status") != "fixed"
                    and prev.get("status") in ("exited", "dead", None)
                )

                if (was_running and now_failed) or persistently_failed:
                    svc_health["restarts"] = prev.get("restarts", 0) + 1
                    logger.warning("Docker service '%s' failed (exit %s)", name, svc.get("exit_code"))

                    # Circuit breaker: max 3 fixes per service per 10 min
                    now = time.time()
                    recent_fixes = [t for t in svc_health["fix_timestamps"] if now - t < 600]

                    if len(recent_fixes) < 3:
                        # BUG-V63-009: Prevent dual monitors from racing to auto-fix
                        if info.get("_auto_fixing"):
                            svc_health["fix_status"] = "fix_in_progress"
                            info["docker_service_health"][name] = svc_health
                            continue

                        # BUG-V64-004: Skip if a fix task is already running for this service
                        existing_task = info.get("_fix_tasks", {}).get(name)
                        if existing_task and not existing_task.done():
                            svc_health["fix_status"] = "fix_in_progress"
                            info["docker_service_health"][name] = svc_health
                            continue  # Skip, previous fix still running

                        info["_auto_fixing"] = True
                        svc_logs = docker_ctx.get("service_logs", {}).get(name, "")

                        # Read compose file for AI context
                        compose_content = ""
                        compose_file = project_dir / "docker-compose.yml"
                        if not compose_file.exists():
                            compose_file = project_dir / "docker-compose.yaml"
                        if compose_file.exists():
                            try:
                                compose_content = compose_file.read_text(errors="replace")[:5000]
                            except OSError:
                                pass

                        # Read service Dockerfile for AI context
                        dockerfile_content = ""
                        for df_path in [project_dir / name / "Dockerfile", project_dir / "Dockerfile"]:
                            if df_path.exists():
                                try:
                                    dockerfile_content = df_path.read_text(errors="replace")[:3000]
                                    break
                                except OSError:
                                    pass

                        fix_prompt = f"""You are debugging a Docker Compose service that has failed.

SERVICE: {name}
STATUS: exited/dead (exit code {svc.get('exit_code', 1)})

DOCKER COMPOSE LOGS (last 50 lines):
{svc_logs[:3000]}

DOCKER-COMPOSE.YML:
{compose_content}
"""
                        if dockerfile_content:
                            fix_prompt += f"\nDOCKERFILE ({name}/Dockerfile):\n{dockerfile_content}\n"

                        fix_prompt += """
INSTRUCTIONS:
1. Analyze the error in the logs above
2. Identify the root cause
3. Fix the issue by editing the necessary files (docker-compose.yml, Dockerfile, source code, package.json, requirements.txt, etc.)
4. Make sure the fix works on any platform (Docker Desktop, Linux, Docker-in-Docker, Kubernetes)
5. Do NOT just restart -- actually fix the underlying code/config problem
6. After fixing, the system will rebuild with 'docker compose up --build'
7. Common issues: named volumes for node_modules (use anonymous), missing dependencies, port conflicts, wrong commands"""

                        svc_health["fix_attempts"] += 1
                        svc_health["fix_timestamps"] = recent_fixes + [now]
                        svc_health["fix_status"] = "fixing"

                        # BUG-V64-003: Track fix tasks so they can be cancelled on stop
                        if "_fix_tasks" not in info:
                            info["_fix_tasks"] = {}
                        task = asyncio.ensure_future(self._auto_fix_service(session_id, name, fix_prompt))
                        info["_fix_tasks"][name] = task
                    else:
                        svc_health["fix_status"] = "circuit_breaker_open"

                info["docker_service_health"][name] = svc_health

            # Broadcast service health via WebSocket
            try:
                await _broadcast({
                    "type": "service_health",
                    "data": {
                        "session_id": session_id,
                        "services": list(info["docker_service_health"].values()),
                    }
                })
            except Exception:
                pass

    async def _auto_fix_service(self, session_id: str, service_name: str, fix_prompt: str) -> None:
        """Run targeted fix for a specific Docker service."""
        info = self.servers.get(session_id)
        if not info:
            return
        project_dir = info.get("project_dir", ".")
        loki = _find_loki_cli()
        if not loki:
            info["_auto_fixing"] = False
            return

        try:
            fix_env = {**os.environ, **_load_secrets()}
            fix_env["LOKI_MAX_ITERATIONS"] = "5"  # More iterations for complex Docker fixes
            # Pass provider via env
            _sf_prov = Path(project_dir) / ".loki" / "state" / "provider"
            if _sf_prov.exists():
                try:
                    _sfp = _sf_prov.read_text().strip()
                    if _sfp:
                        fix_env["LOKI_PROVIDER"] = _sfp
                except OSError:
                    pass

            proc = await asyncio.to_thread(
                subprocess.run,
                [loki, "quick", fix_prompt],
                capture_output=True, text=True, cwd=project_dir, timeout=300,
                env=fix_env
            )

            # Rebuild the image (fix may have changed Dockerfile or package.json)
            # --no-deps prevents restarting healthy services, --build rebuilds with the fix
            await asyncio.to_thread(
                subprocess.run,
                ["docker", "compose", "up", "-d", "--build", "--no-deps", service_name],
                capture_output=True, cwd=project_dir, timeout=120
            )

            # Wait for service to stabilize after rebuild
            await asyncio.sleep(10)

            # Verify the fix actually worked
            fix_worked = False
            try:
                verify_proc = await asyncio.to_thread(
                    subprocess.run,
                    ["docker", "compose", "ps", "-a", "--format", "json"],
                    capture_output=True, text=True, cwd=project_dir, timeout=10
                )
                if verify_proc.returncode == 0 and verify_proc.stdout.strip():
                    raw = verify_proc.stdout.strip()
                    try:
                        parsed = json.loads(raw)
                        if not isinstance(parsed, list):
                            parsed = [parsed]
                    except json.JSONDecodeError:
                        parsed = []
                        for line in raw.split("\n"):
                            if line.strip():
                                try:
                                    parsed.append(json.loads(line))
                                except json.JSONDecodeError:
                                    pass
                    for svc in parsed:
                        if isinstance(svc, dict) and svc.get("Name", svc.get("name", "")) == service_name:
                            state = svc.get("State", svc.get("state", ""))
                            fix_worked = state == "running"
                            break
            except Exception:
                logger.debug("Post-fix verification failed for service '%s'", service_name, exc_info=True)

            # Determine final fix status
            if proc.returncode == 0 and fix_worked:
                final_status = "fixed"
            elif proc.returncode == 0 and not fix_worked:
                final_status = "fix_failed"
                logger.warning("loki quick succeeded but service '%s' still not running", service_name)
            else:
                final_status = "fix_failed"

            # BUG-V64-005: Re-fetch from live info dict to avoid writing to detached dict
            info = self.servers.get(session_id)
            if info and "docker_service_health" in info and service_name in info["docker_service_health"]:
                info["docker_service_health"][service_name]["fix_status"] = final_status
        except Exception as exc:
            logger.error("Auto-fix for service '%s' failed: %s", service_name, exc)
            # BUG-V64-005: Re-fetch from live info dict
            info = self.servers.get(session_id)
            if info and "docker_service_health" in info and service_name in info["docker_service_health"]:
                info["docker_service_health"][service_name]["fix_status"] = "fix_failed"
        finally:
            # BUG-V63-009: Clear the auto-fixing lock
            info = self.servers.get(session_id)
            if info:
                info["_auto_fixing"] = False

    async def stop_all(self) -> None:
        """Stop all dev servers (used on shutdown)."""
        for sid in list(self.servers.keys()):
            await self.stop(sid)


dev_server_manager = DevServerManager()


# ---------------------------------------------------------------------------
# Docker context gathering and error diagnosis
# ---------------------------------------------------------------------------


_docker_context_cache: dict[str, tuple[float, dict]] = {}


async def _gather_docker_context(project_dir: Path) -> dict:
    """Gather Docker Compose service status, logs, and project context."""
    cache_key = str(project_dir)
    now = time.time()
    cached = _docker_context_cache.get(cache_key)
    if cached and now - cached[0] < 30:
        return cached[1]

    loop = asyncio.get_running_loop()
    result: dict = {"service_status": [], "failing_services": [], "service_logs": {},
                    "project_structure": "", "env_keys": []}

    # Get service status via docker compose ps
    try:
        ps_proc = await loop.run_in_executor(None, lambda: subprocess.run(
            ["docker", "compose", "ps", "-a", "--format", "json"],
            capture_output=True, text=True, cwd=str(project_dir), timeout=10
        ))
        if ps_proc.returncode == 0 and ps_proc.stdout.strip():
            raw = ps_proc.stdout.strip()
            # Handle both NDJSON (one object per line) and JSON array formats
            # Docker Compose v2.21+ returns a JSON array instead of NDJSON
            services_data: list = []
            try:
                parsed = json.loads(raw)
                if isinstance(parsed, list):
                    services_data = parsed
                else:
                    services_data = [parsed]
            except json.JSONDecodeError:
                # NDJSON format - one JSON object per line
                for line in raw.split("\n"):
                    if line.strip():
                        try:
                            services_data.append(json.loads(line))
                        except json.JSONDecodeError:
                            pass
            for svc in services_data:
                if not isinstance(svc, dict):
                    continue
                status_entry = {
                    "name": svc.get("Service", svc.get("Name", "unknown")),
                    "state": svc.get("State", "unknown"),
                    "status": svc.get("Status", ""),
                    "exit_code": svc.get("ExitCode", 0),
                }
                result["service_status"].append(status_entry)
                if status_entry["state"] in ("exited", "dead", "restarting"):
                    result["failing_services"].append(status_entry["name"])
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    # Get logs for failing services
    for svc_name in result["failing_services"]:
        try:
            log_proc = await loop.run_in_executor(None, lambda sn=svc_name: subprocess.run(
                ["docker", "compose", "logs", "--tail", "50", sn],
                capture_output=True, text=True, cwd=str(project_dir), timeout=15
            ))
            if log_proc.stdout:
                result["service_logs"][svc_name] = log_proc.stdout[-3000:]  # Cap at 3KB
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            pass

    # If no specific failures, get combined logs tail
    if not result["failing_services"]:
        try:
            log_proc = await loop.run_in_executor(None, lambda: subprocess.run(
                ["docker", "compose", "logs", "--tail", "30"],
                capture_output=True, text=True, cwd=str(project_dir), timeout=15
            ))
            if log_proc.stdout:
                result["service_logs"]["_combined"] = log_proc.stdout[-3000:]
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            pass

    # Project structure
    try:
        ls_proc = await loop.run_in_executor(None, lambda: subprocess.run(
            ["find", ".", "-maxdepth", "2", "-type", "f", "(",
             "-name", "*.py", "-o", "-name", "*.ts",
             "-o", "-name", "*.tsx", "-o", "-name", "*.js", "-o", "-name", "package.json",
             "-o", "-name", "requirements.txt", "-o", "-name", "Dockerfile",
             "-o", "-name", "docker-compose.yml", "-o", "-name", "*.env", ")"],
            capture_output=True, text=True, cwd=str(project_dir), timeout=5
        ))
        result["project_structure"] = ls_proc.stdout[:2000] if ls_proc.stdout else ""
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    # Env variable names (not values)
    env_file = project_dir / ".env"
    if env_file.exists():
        try:
            for line in env_file.read_text(errors="replace").split("\n"):
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    result["env_keys"].append(line.split("=", 1)[0])
        except OSError:
            pass

    _docker_context_cache[cache_key] = (time.time(), result)
    return result


def _diagnose_errors(logs: str) -> list[dict]:
    """Pattern-match common errors in Docker/service logs and return diagnoses."""
    diagnoses: list[dict] = []
    patterns = [
        (r"ModuleNotFoundError: No module named ['\"](\w+)['\"]",
         lambda m: {"pattern": "missing_python_dep", "diagnosis": f"Missing Python dependency: {m.group(1)}",
                     "suggestion": f"Add '{m.group(1)}' to requirements.txt and rebuild"}),
        (r"Cannot find module ['\"]([^'\"]+)['\"]|ERR_MODULE_NOT_FOUND",
         lambda m: {"pattern": "missing_node_dep", "diagnosis": "Missing Node.js module",
                     "suggestion": "Run 'npm install' in the service directory"}),
        (r"ECONNREFUSED.*:(\d+)|connection refused.*:(\d+)",
         lambda m: {"pattern": "connection_refused",
                     "diagnosis": f"Connection refused on port {m.group(1) or m.group(2)}",
                     "suggestion": "A dependent service may not be ready. Add retry logic or health check wait."}),
        (r"address already in use|EADDRINUSE",
         lambda m: {"pattern": "port_conflict", "diagnosis": "Port already in use",
                     "suggestion": "Another process is using the port. Change the port or stop the conflicting process."}),
        (r"SyntaxError: (.+)",
         lambda m: {"pattern": "syntax_error", "diagnosis": f"Syntax error: {m.group(1)[:100]}",
                     "suggestion": "Fix the syntax error in the indicated file and line."}),
        (r"FATAL:.*password authentication failed",
         lambda m: {"pattern": "db_auth", "diagnosis": "Database authentication failed",
                     "suggestion": "Check DATABASE_URL credentials match the postgres service environment."}),
        (r"error.*returned a non-zero code: (\d+)",
         lambda m: {"pattern": "build_failure", "diagnosis": f"Docker build failed (exit code {m.group(1)})",
                     "suggestion": "Check the Dockerfile for errors. Common: missing system dependencies."}),
        (r"npm ERR!|npm error",
         lambda m: {"pattern": "npm_error", "diagnosis": "npm encountered an error",
                     "suggestion": "Check package.json for invalid dependencies or run 'npm install' manually."}),
        (r"ENOTEMPTY.*node_modules|rename.*node_modules.*ENOTEMPTY",
         lambda m: {"pattern": "node_modules_volume_conflict", "diagnosis": "Docker volume conflict with node_modules",
                     "suggestion": "Replace named volume with anonymous volume in docker-compose.yml: use '- /app/node_modules' instead of '- name:/app/node_modules'. Then run 'docker compose down -v && docker compose up --build'."}),
        (r"EACCES.*permission denied|EPERM.*operation not permitted",
         lambda m: {"pattern": "permission_denied", "diagnosis": "File permission error in container",
                     "suggestion": "Check Dockerfile USER directive and volume mount permissions. May need 'chown' in Dockerfile."}),
    ]
    seen: set[str] = set()
    for pattern, handler in patterns:
        for match in re.finditer(pattern, logs, re.IGNORECASE):
            diag = handler(match)
            key = diag["pattern"]
            if key not in seen:
                seen.add(key)
                diagnoses.append(diag)
    return diagnoses


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _loki_dir() -> Path:
    """Return the .loki/ directory for the current session project."""
    if session.project_dir:
        return Path(session.project_dir) / ".loki"
    return Path.home() / ".loki"


def _safe_resolve(base: Path, requested: str) -> Optional[Path]:
    """Resolve a path ensuring it stays within base (path traversal protection).

    Uses os.path.commonpath to avoid the startswith prefix collision where
    /tmp/proj would incorrectly pass a check against /tmp/projother.
    Also rejects symlinks that escape the base directory.
    """
    try:
        resolved = (base / requested).resolve()
        base_resolved = base.resolve()
        # Ensure resolved is strictly inside base_resolved
        resolved.relative_to(base_resolved)
        # Reject if any component is a symlink pointing outside base
        check = base_resolved
        for part in resolved.relative_to(base_resolved).parts:
            check = check / part
            if check.is_symlink():
                link_target = check.resolve()
                link_target.relative_to(base_resolved)  # raises ValueError if outside
        return resolved
    except (ValueError, OSError):
        pass
    return None


def _find_session_dir(session_id: str) -> Optional[Path]:
    """Find a session's project directory by ID."""
    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            return candidate
    return None


async def _broadcast(msg: dict) -> None:
    """Send a JSON message to all connected WebSocket clients."""
    data = json.dumps(msg)
    dead: list[WebSocket] = []
    for ws in list(session.ws_clients):
        try:
            await ws.send_text(data)
        except Exception:
            logger.debug("WebSocket send failed for client", exc_info=True)
            dead.append(ws)
    for ws in dead:
        session.ws_clients.discard(ws)


async def _read_process_output(generation: int) -> None:
    """Background task: read loki stdout/stderr and broadcast lines.

    ``generation`` ties this reader to a specific session start.  The finally
    block only clears ``session.running`` when the generation still matches,
    preventing a stale reader from a previous session from clobbering the
    running flag of a newer session (BUG-RACE-001).
    """
    proc = session.process
    if proc is None or proc.stdout is None:
        return

    loop = asyncio.get_running_loop()

    try:
        while session.running and proc.poll() is None:
            line = await loop.run_in_executor(None, proc.stdout.readline)
            if not line:
                break
            text = line.rstrip("\n")
            session.log_lines.append(text)
            session.log_lines_total += 1
            # Keep last 5000 lines
            if len(session.log_lines) > 5000:
                session.log_lines = session.log_lines[-5000:]
            # BUG-E2E-002: Include sequence number so frontend can detect
            # gaps and maintain correct ordering even under async pressure
            await _broadcast({
                "type": "log",
                "data": {
                    "line": text,
                    "timestamp": time.strftime("%H:%M:%S"),
                    "seq": session.log_lines_total,
                },
            })
    except Exception:
        logger.error("Process output reader failed", exc_info=True)
    finally:
        # Process ended -- acquire lock before mutating state.
        # Only clear running if this reader still owns the current session;
        # a newer session may have bumped _generation already (BUG-RACE-001).
        is_current = False
        async with session._lock:
            if session._generation == generation:
                session.running = False
                is_current = True
        # Only broadcast session_end for the current session; a stale
        # reader from a previous session must not signal the UI.
        if is_current:
            await _broadcast({"type": "session_end", "data": {"message": "Session ended"}})


def _build_file_tree(root: Path, max_depth: int = 8, _depth: int = 0) -> list[dict]:
    """Recursively build a file tree from a directory.

    max_depth is set to 8 to support monorepo structures like
    packages/frontend/src/components/ui/Button.tsx (6 levels deep).
    """
    if _depth >= max_depth or not root.is_dir():
        return []

    entries = []
    try:
        items = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
    except PermissionError:
        return []

    # Limit children per directory to prevent memory issues on very large projects
    MAX_CHILDREN = 500
    child_count = 0

    for item in items:
        # Skip hidden dirs and common noise
        if item.name.startswith("."):
            continue
        if item.name in ("node_modules", "__pycache__", ".git", "venv", ".venv",
                         "vendor", ".turbo", ".nx", "coverage", ".parcel-cache"):
            continue

        child_count += 1
        if child_count > MAX_CHILDREN:
            entries.append({
                "name": f"... ({len(list(root.iterdir())) - MAX_CHILDREN} more items)",
                "path": str(root.relative_to(root)) + "/...",
                "type": "file",
                "size": 0,
            })
            break

        node: dict = {"name": item.name, "path": str(item.relative_to(root))}
        if item.is_dir():
            node["type"] = "directory"
            node["children"] = _build_file_tree(item, max_depth, _depth + 1)
        else:
            node["type"] = "file"
            try:
                node["size"] = item.stat().st_size
            except OSError:
                node["size"] = 0
        entries.append(node)
    return entries


# ---------------------------------------------------------------------------
# Secrets management (plaintext -- this is a local dev tool, not a vault)
# ---------------------------------------------------------------------------

_SECRETS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "secrets.json"


def _load_secrets() -> dict[str, str]:
    """Load secrets from disk, decrypting values if encryption is configured."""
    try:
        from crypto import decrypt_value, encryption_available
    except ImportError:
        # crypto module not available -- return raw secrets or empty dict
        if _SECRETS_FILE.exists():
            try:
                data = json.loads(_SECRETS_FILE.read_text())
                if isinstance(data, dict):
                    return data
            except (json.JSONDecodeError, OSError):
                pass
        return {}
    if _SECRETS_FILE.exists():
        try:
            data = json.loads(_SECRETS_FILE.read_text())
            if isinstance(data, dict):
                if encryption_available():
                    return {k: decrypt_value(v) for k, v in data.items()}
                return data
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_secrets(secrets: dict[str, str]) -> None:
    """Save secrets to disk, encrypting values if PURPLE_LAB_SECRET_KEY is set."""
    _SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
    try:
        from crypto import encrypt_value, encryption_available
        if encryption_available():
            encrypted = {k: encrypt_value(v) for k, v in secrets.items()}
            _SECRETS_FILE.write_text(json.dumps(encrypted, indent=2))
            return
    except ImportError:
        pass
    # Fallback: save as plaintext when crypto module is not available
    _SECRETS_FILE.write_text(json.dumps(secrets, indent=2))


# ---------------------------------------------------------------------------
# Chat task tracking (non-blocking chat via polling)
# ---------------------------------------------------------------------------


class ChatTask:
    def __init__(self) -> None:
        self.id = str(uuid.uuid4())[:8]
        self.output_lines: list[str] = []
        self.complete = False
        self.returncode: int = -1
        self.files_changed: list[str] = []
        self.process: Optional[subprocess.Popen] = None
        self.cancelled = False
        self.created_at: float = time.time()


_chat_tasks: dict[str, ChatTask] = {}
_CHAT_TASK_MAX_AGE = 600  # Seconds to keep completed tasks before cleanup
_CHAT_TASK_MAX_COUNT = 100  # Max tasks to keep in memory


def _cleanup_chat_tasks() -> None:
    """Remove completed tasks older than _CHAT_TASK_MAX_AGE, or oldest if over limit."""
    now = time.time()
    # Remove old completed tasks
    expired = [
        tid for tid, t in _chat_tasks.items()
        if t.complete and (now - t.created_at) > _CHAT_TASK_MAX_AGE
    ]
    for tid in expired:
        del _chat_tasks[tid]
    # If still over limit, remove oldest completed tasks
    if len(_chat_tasks) > _CHAT_TASK_MAX_COUNT:
        completed = sorted(
            [(tid, t) for tid, t in _chat_tasks.items() if t.complete],
            key=lambda x: x[1].created_at,
        )
        while len(_chat_tasks) > _CHAT_TASK_MAX_COUNT and completed:
            tid, _ = completed.pop(0)
            del _chat_tasks[tid]

# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------


@app.post("/api/session/start")
async def start_session(req: StartRequest) -> JSONResponse:
    """Start a new loki session with the given PRD."""
    logger.info("START_SESSION called: provider=%s, projectDir=%s, mode=%s, prd_len=%d",
                req.provider, req.projectDir, req.mode, len(req.prd))
    if len(req.prd.encode()) > _MAX_PRD_BYTES:
        return JSONResponse(status_code=400, content={"error": "PRD exceeds 1 MB limit"})

    async with session._lock:
        if session.running:
            return JSONResponse(
                status_code=409,
                content={"error": "A session is already running. Stop it first."},
            )

        # Clean up any stale reader task from previous session
        await session.cleanup()

        # Determine project directory
        project_dir = req.projectDir
        if not project_dir:
            project_dir = os.path.join(Path.home(), "purple-lab-projects", f"project-{int(time.time() * 1000)}")
        else:
            # Validate user-supplied path stays within home directory
            try:
                resolved_dir = Path(project_dir).resolve()
                resolved_dir.relative_to(Path.home().resolve())
            except (ValueError, OSError):
                return JSONResponse(
                    status_code=400,
                    content={"error": "Project directory must be within your home directory"},
                )
        os.makedirs(project_dir, exist_ok=True)

        # Write PRD to a temp file in the project dir
        prd_path = os.path.join(project_dir, "PRD.md")
        with open(prd_path, "w") as f:
            f.write(req.prd)

        # Build the loki start command
        if req.mode == "quick":
            # Extract first non-blank line as the task description
            first_line = next((l.strip() for l in req.prd.splitlines() if l.strip()), req.prd[:200])
            cmd = [
                str(LOKI_CLI),
                "quick",
                first_line,
            ]
        else:
            cmd = [
                str(LOKI_CLI),
                "start",
                "--provider", req.provider,
                prd_path,
            ]

        try:
            # Load secrets and inject as env vars
            build_env = {**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")}
            build_env.update(_load_secrets())

            # BUG-INT-001 fix: pass provider selection via LOKI_PROVIDER env var
            # for quick mode (loki quick doesn't accept --provider flag)
            if req.provider:
                build_env["LOKI_PROVIDER"] = req.provider

            # LOKI_CLI is a trusted fixed path; request data stays in an argv
            # vector and is never parsed as shell command text.
            proc = subprocess.Popen(
                cmd,  # lgtm[py/command-line-injection]
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=project_dir,
                env=build_env,
                shell=False,
                **({"start_new_session": True} if sys.platform != "win32"
                   else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
            )
        except FileNotFoundError:
            return JSONResponse(
                status_code=500,
                content={"error": f"loki CLI not found at {LOKI_CLI}"},
            )
        except Exception as e:
            return JSONResponse(
                status_code=500,
                content={"error": f"Failed to start session: {e}"},
            )

        logger.info("START_SESSION: process spawned pid=%d, cmd=%s", proc.pid, cmd)

        # Update session state
        session.reset()
        session.process = proc
        session.running = True
        session.provider = req.provider
        session.prd_text = req.prd
        session.project_dir = project_dir
        session.start_time = time.time()

        # Bump generation so stale reader tasks from previous sessions
        # cannot clobber this session's running flag (BUG-RACE-001).
        session._generation += 1
        current_gen = session._generation

        # Track this PID so loki web stop knows it's ours
        _track_child_pid(proc.pid)

        # Start background output reader -- pass generation so its finally
        # block only clears running if it still belongs to the current session.
        session._reader_task = asyncio.create_task(
            _read_process_output(current_gen)
        )

        # Start file watcher for the project directory
        file_watcher.start(
            "session",
            project_dir,
            _broadcast,
            asyncio.get_running_loop(),
        )

    await _broadcast({"type": "session_start", "data": {
        "provider": req.provider,
        "projectDir": project_dir,
        "pid": proc.pid,
    }})

    # Notify the Loki Dashboard (port 57374) about the active project so it
    # reads state files from the correct .loki/ directory (BUG-FOCUS-001).
    try:
        os.makedirs(os.path.join(project_dir, ".loki"), exist_ok=True)
        import urllib.request
        focus_data = json.dumps({"project_dir": project_dir}).encode()
        focus_req = urllib.request.Request(
            "http://127.0.0.1:57374/api/focus",
            data=focus_data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        urllib.request.urlopen(focus_req, timeout=2)
        logger.info("Notified dashboard of project focus: %s", project_dir)
    except Exception:
        logger.debug("Could not notify dashboard (may not be running)")

    return JSONResponse(content={
        "started": True,
        "pid": proc.pid,
        "projectDir": project_dir,
        "provider": req.provider,
    })


@app.post("/api/session/quick-start")
async def quick_start_session(req: QuickStartRequest) -> JSONResponse:
    """Start a build from a one-line prompt. Auto-generates PRD and starts immediately."""
    prompt = req.prompt.strip()
    if not prompt:
        return JSONResponse(status_code=400, content={"error": "Prompt required"})
    # BUG-E2E-001: Validate minimum prompt length to prevent degenerate builds
    if len(prompt) < 3:
        return JSONResponse(status_code=400, content={"error": "Prompt too short (minimum 3 characters)"})
    if len(prompt.encode()) > _MAX_PRD_BYTES:
        return JSONResponse(status_code=400, content={"error": "Prompt exceeds size limit"})

    # Auto-generate a PRD from the one-liner
    prd_content = f"""# {prompt}

## Overview
{prompt}

## Requirements
- Implement the feature described above
- Create a clean, modern UI
- Include proper error handling
- Write tests where applicable
- Include Dockerfile and docker-compose.yml for containerized deployment

## Tech Stack
- Choose appropriate technologies based on the requirements
- Use modern frameworks and best practices

## Success Criteria
- The application works as described
- Clean, maintainable code
- Runs via docker compose up
"""

    # Delegate to start_session with the generated PRD
    start_req = StartRequest(prd=prd_content, provider=req.provider)
    result = await start_session(start_req)

    # Unwrap the JSONResponse to add session_id
    body = json.loads(result.body.decode())
    if body.get("started"):
        project_dir = body.get("projectDir", "")
        session_id = os.path.basename(project_dir)
        body["session_id"] = session_id
        body["project_dir"] = project_dir

    return JSONResponse(status_code=result.status_code, content=body)


@app.post("/api/session/stop")
async def stop_session() -> JSONResponse:
    """Stop the current loki session."""
    async with session._lock:
        if not session.running or session.process is None:
            return JSONResponse(content={"stopped": False, "message": "No session running"})

        proc = session.process

        # 1. Mark stopped first so reader task loop exits
        session.running = False

        # 2. Cancel reader task before killing process
        await session.cleanup()

        # 3. Kill the process group (catches child processes too)
        if sys.platform != "win32":
            try:
                pgid = os.getpgid(proc.pid)
                os.killpg(pgid, signal.SIGTERM)
            except (ProcessLookupError, PermissionError, OSError):
                try:
                    proc.terminate()
                except Exception:
                    pass
        else:
            try:
                subprocess.call(["taskkill", "/F", "/T", "/PID", str(proc.pid)])
            except Exception:
                try:
                    proc.terminate()
                except Exception:
                    pass

        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            if sys.platform != "win32":
                try:
                    pgid = os.getpgid(proc.pid)
                    os.killpg(pgid, signal.SIGKILL)
                except (ProcessLookupError, PermissionError, OSError):
                    try:
                        proc.kill()
                    except Exception:
                        pass
            else:
                try:
                    proc.kill()
                except Exception:
                    pass
            try:
                proc.wait(timeout=3)
            except Exception:
                pass

        # Stop file watcher
        file_watcher.stop("session")

        # Stop dev server if running for this session
        # (The current active session uses "session" as its key for file_watcher
        # but dev servers are keyed by session_id from the URL. Stop all to be safe.)
        await dev_server_manager.stop_all()

        # Clean up any terminal PTYs
        for sid, pty in list(_terminal_ptys.items()):
            try:
                if pty.isalive():
                    pty.close(force=True)
            except Exception:
                pass
        _terminal_ptys.clear()
        _terminal_ws_clients.clear()
        _terminal_reader_tasks.clear()

        # Kill any orphaned loki-run processes for this project
        if session.project_dir:
            await asyncio.get_running_loop().run_in_executor(
                None, _kill_tracked_child_processes
            )

        await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})

        # Reset session state so it can be reused
        session.reset()

    return JSONResponse(content={"stopped": True, "message": "Session stopped"})


def _measured_state_cost(tokens) -> Optional[float]:
    """Cost from dashboard-state.json's tokens block, or None when unmeasured.

    Shared by GET /api/session/status and the WebSocket status push. A zero
    cost with no token counted is an unmeasured record, not a free run.
    """
    if not isinstance(tokens, dict):
        return None
    cost = tokens.get("cost_usd")
    if isinstance(cost, bool) or not isinstance(cost, (int, float)):
        return None
    counted = any(isinstance(tokens.get(k), (int, float)) and not isinstance(tokens.get(k), bool)
                  and tokens.get(k) > 0 for k in ("input", "output", "input_tokens", "output_tokens"))
    return float(cost) if cost > 0 or counted else None


def _max_iterations_or_none(configured: int) -> Optional[int]:
    """The configured iteration cap, else LOKI_MAX_ITERATIONS, else None (never an invented 10)."""
    if configured > 0:
        return configured
    try:
        env = int(os.environ.get("LOKI_MAX_ITERATIONS", ""))
    except ValueError:
        return None
    return env if env > 0 else None


@app.get("/api/session/status")
async def get_status() -> JSONResponse:
    """Get current session status."""
    # Check if process is still alive (read-only -- do not mutate session.running
    # here; that is handled by _read_process_output under the lock)
    is_running = session.running
    poll_result = session.process.poll() if session.process else None
    if session.process and is_running:
        if poll_result is not None:
            logger.info("STATUS: process pid=%d exited with code=%s, session.running=%s, elapsed=%.1fs",
                        session.process.pid, poll_result, session.running, time.time() - session.start_time)
            # BUG-RACE-001: The process has exited, but if the session was
            # started very recently (within 5 seconds), report "running" with
            # a "starting" phase so the UI does not flash "stopped" before the
            # build has had time to initialise.  The reader task's finally
            # block will clear session.running properly once it catches up.
            elapsed_since_start = time.time() - session.start_time
            if elapsed_since_start < 5.0:
                # Keep is_running True -- the reader task hasn't caught up yet
                pass
            else:
                is_running = False

    # Try to read .loki state files for richer status. A field no state file
    # carries stays None ("--" in the UI): no invented iteration 0, "standard"
    # complexity, 10-iteration cap or $0.00 cost.
    loki_dir = _loki_dir()
    phase = "idle"
    iteration = None
    complexity = None
    current_task = ""
    pending_tasks = None
    max_iterations = 0
    cost_usd = None

    # BUG-INT-002 fix: CLI writes dashboard-state.json, not state/session.json.
    # Read from dashboard-state.json (primary) with orchestrator.json fallback.
    dash_state_file = loki_dir / "dashboard-state.json"
    if dash_state_file.exists():
        try:
            with open(dash_state_file) as f:
                state = json.load(f)
            phase = state.get("phase", phase)
            iteration = state.get("iteration", iteration)
            complexity = state.get("complexity", complexity)
            # Tasks are nested in dashboard-state.json
            tasks = state.get("tasks")
            if isinstance(tasks, dict):
                _p = tasks.get("pending", 0)
                _ip = tasks.get("inProgress", 0)
                pending_tasks = len(_p) if isinstance(_p, list) else int(_p or 0)
                in_progress = len(_ip) if isinstance(_ip, list) else int(_ip or 0)
                if in_progress > 0:
                    current_task = f"{in_progress} task(s) in progress"
            cost_usd = _measured_state_cost(state.get("tokens"))
        except (json.JSONDecodeError, OSError):
            pass
    else:
        # Fallback: try orchestrator.json for phase/iteration
        orch_file = loki_dir / "state" / "orchestrator.json"
        if orch_file.exists():
            try:
                with open(orch_file) as f:
                    orch = json.load(f)
                phase = orch.get("currentPhase", phase)
            except (json.JSONDecodeError, OSError):
                pass

    # Read max_iterations from autonomy state
    autonomy_state = loki_dir / "autonomy-state.json"
    if autonomy_state.exists():
        try:
            with open(autonomy_state) as f:
                astate = json.load(f)
            max_iterations = int(astate.get("maxIterations", 0) or 0)
        except (json.JSONDecodeError, OSError, ValueError):
            pass

    # Fall back: read LOKI_MAX_ITERATIONS env or config
    if max_iterations <= 0:
        config_file = loki_dir / "config.yaml"
        if config_file.exists():
            try:
                with open(config_file) as f:
                    for line in f:
                        if "max_iterations" in line:
                            parts = line.split(":")
                            if len(parts) >= 2:
                                max_iterations = int(parts[-1].strip())
                                break
            except (OSError, ValueError):
                pass
    max_iterations = _max_iterations_or_none(max_iterations)

    uptime = time.time() - session.start_time if is_running else 0

    # BUG-RACE-001: If the session is running but no phase has been written
    # to disk yet (the loki process is still initialising), report a
    # "starting" phase so the UI shows meaningful feedback instead of "idle".
    if is_running and phase == "idle" and session.start_time > 0:
        elapsed_since_start = time.time() - session.start_time
        if elapsed_since_start < 15.0:
            phase = "starting"

    # If process has exited, include exit code and last output for debugging
    exit_code = None
    last_output = []
    if session.process and session.process.poll() is not None:
        exit_code = session.process.returncode
        last_output = session.log_lines[-20:] if session.log_lines else []

    return JSONResponse(content={
        "running": is_running,
        "paused": session.paused,
        "phase": phase,
        "iteration": iteration,
        "complexity": complexity,
        "mode": "autonomous",
        "provider": session.provider,
        "current_task": current_task,
        "pending_tasks": pending_tasks,
        # Not tracked by this server: unknown, never a measured 0.
        "running_agents": None,
        "uptime": round(uptime),
        "version": "",
        "pid": str(session.process.pid) if session.process else "",
        "projectDir": session.project_dir,
        "max_iterations": max_iterations,
        "cost": round(cost_usd, 4) if cost_usd is not None else None,
        "start_time": session.start_time if session.start_time > 0 else 0,
        "exit_code": exit_code,
        "last_output": last_output,
    })


@app.get("/api/session/logs")
async def get_logs(lines: int = 200) -> JSONResponse:
    """Get recent log lines."""
    recent = session.log_lines[-lines:] if session.log_lines else []
    entries = []
    for line in recent:
        level = "info"
        lower = line.lower()
        if "error" in lower or "fail" in lower:
            level = "error"
        elif "warn" in lower:
            level = "warning"
        elif "debug" in lower:
            level = "debug"
        entries.append({
            "timestamp": "",
            "level": level,
            "message": line,
            "source": "loki",
        })
    return JSONResponse(content=entries)


@app.get("/api/session/agents")
async def get_agents() -> JSONResponse:
    """Get agent status from .loki state."""
    loki_dir = _loki_dir()
    agents_file = loki_dir / "state" / "agents.json"
    if agents_file.exists():
        try:
            with open(agents_file) as f:
                agents = json.load(f)
            if isinstance(agents, list):
                return JSONResponse(content=agents)
        except (json.JSONDecodeError, OSError):
            pass
    return JSONResponse(content=[])


@app.get("/api/session/files")
async def get_files() -> JSONResponse:
    """Get the project file tree."""
    if not session.project_dir:
        return JSONResponse(content=[])

    root = Path(session.project_dir)
    if not root.is_dir():
        return JSONResponse(content=[])

    tree = _build_file_tree(root)
    return JSONResponse(content=tree)


@app.get("/api/session/files/content")
async def get_file_content(path: str = "") -> JSONResponse:
    """Get file content with path traversal protection."""
    if not session.project_dir or not path:
        return JSONResponse(status_code=400, content={"error": "No active session or path"})

    base = Path(session.project_dir).resolve()
    resolved = _safe_resolve(base, path)
    if resolved is None or not resolved.is_file():
        return JSONResponse(status_code=404, content={"error": "File not found"})

    # Limit file size to 1MB
    try:
        size = resolved.stat().st_size
        if size > 1_048_576:
            return JSONResponse(
                status_code=413,
                content={"error": f"File too large ({size:,} bytes, limit 1MB)"},
            )
        content = resolved.read_text(errors="replace")
    except OSError as e:
        return JSONResponse(
            status_code=500,
            content={"error": f"Cannot read file: {e}"},
        )
    except UnicodeDecodeError as e:
        return JSONResponse(
            status_code=422,
            content={"error": f"Binary or unreadable file: {e}"},
        )

    return JSONResponse(content={"content": content})


@app.get("/api/session/memory")
async def get_memory() -> JSONResponse:
    """Get memory summary from .loki state."""
    loki_dir = _loki_dir()
    memory_dir = loki_dir / "memory"
    if not memory_dir.is_dir():
        return JSONResponse(content={
            "episodic_count": 0,
            "semantic_count": 0,
            "skill_count": 0,
            # Not tracked by this reader: unknown, not 0.
            "total_tokens": None,
            "last_consolidation": None,
        })

    episodic = len(list((memory_dir / "episodic").glob("*.json"))) if (memory_dir / "episodic").is_dir() else 0
    semantic = len(list((memory_dir / "semantic").glob("*.json"))) if (memory_dir / "semantic").is_dir() else 0
    skills = len(list((memory_dir / "skills").glob("*.json"))) if (memory_dir / "skills").is_dir() else 0

    return JSONResponse(content={
        "episodic_count": episodic,
        "semantic_count": semantic,
        "skill_count": skills,
        "total_tokens": None,
        "last_consolidation": None,
    })


@app.get("/api/session/checklist")
async def get_checklist() -> JSONResponse:
    """Get quality gates checklist from .loki state."""
    loki_dir = _loki_dir()
    checklist_file = loki_dir / "state" / "checklist.json"
    if checklist_file.exists():
        try:
            with open(checklist_file) as f:
                data = json.load(f)
            return JSONResponse(content=data)
        except (json.JSONDecodeError, OSError):
            pass
    return JSONResponse(content={
        "total": 0, "passed": 0, "failed": 0, "skipped": 0, "pending": 0, "items": [],
    })


@app.get("/api/session/prd-prefill")
async def get_prd_prefill() -> JSONResponse:
    """Return PRD content from PURPLE_LAB_PRD env var (set by CLI --prd flag)."""
    content = os.environ.get("PURPLE_LAB_PRD")
    return JSONResponse(content={"content": content})


@app.post("/api/session/pause")
async def pause_session() -> JSONResponse:
    """Pause the current loki session by sending SIGUSR1."""
    async with session._lock:
        if not session.running or session.process is None:
            return JSONResponse(content={"paused": False, "message": "No session running"})
        try:
            os.kill(session.process.pid, signal.SIGUSR1)
        except ProcessLookupError:
            return JSONResponse(content={"paused": False, "message": "Process not found"})
        except Exception as e:
            return JSONResponse(content={"paused": False, "message": str(e)})
        session.paused = True
    await _broadcast({"type": "session_paused", "data": {}})
    return JSONResponse(content={"paused": True})


@app.post("/api/session/resume")
async def resume_session() -> JSONResponse:
    """Resume the current loki session by sending SIGUSR2."""
    async with session._lock:
        if not session.running or session.process is None:
            return JSONResponse(content={"resumed": False, "message": "No session running"})
        try:
            os.kill(session.process.pid, signal.SIGUSR2)
        except ProcessLookupError:
            return JSONResponse(content={"resumed": False, "message": "Process not found"})
        except Exception as e:
            return JSONResponse(content={"resumed": False, "message": str(e)})
        session.paused = False
    await _broadcast({"type": "session_resumed", "data": {}})
    return JSONResponse(content={"resumed": True})


@app.get("/api/templates")
async def get_templates() -> JSONResponse:
    """List available PRD templates with description, category, tech stack, difficulty, and build time."""
    templates_dir = PROJECT_ROOT / "templates"
    if not templates_dir.is_dir():
        return JSONResponse(content=[])

    # Category mapping from filename
    _category_map = {
        'static-landing-page': 'Website', 'blog-platform': 'Website', 'e-commerce': 'Website',
        'full-stack-demo': 'Website', 'dashboard': 'Website',
        'rest-api': 'API', 'rest-api-auth': 'API', 'api-only': 'API', 'microservice': 'API',
        'cli-tool': 'CLI', 'npm-library': 'CLI',
        'discord-bot': 'Bot', 'slack-bot': 'Bot', 'ai-chatbot': 'Bot',
        'data-pipeline': 'Data', 'web-scraper': 'Data',
        'saas-starter': 'Website', 'simple-todo-app': 'Website',
        'game': 'Other', 'chrome-extension': 'Other', 'mobile-app': 'Other',
    }

    # Tech stack detection from template content
    _tech_keywords = {
        'React': ['react', 'jsx', 'tsx'],
        'Node.js': ['node.js', 'node 20', 'node 18', 'express'],
        'Python': ['python', 'flask', 'django', 'fastapi'],
        'TypeScript': ['typescript', '.ts'],
        'PostgreSQL': ['postgresql', 'postgres', 'pg'],
        'MongoDB': ['mongodb', 'mongoose'],
        'Docker': ['docker', 'dockerfile', 'docker-compose'],
        'Redis': ['redis', 'valkey'],
        'Express': ['express.js', 'expressjs'],
        'FastAPI': ['fastapi'],
        'SQLite': ['sqlite'],
        'Tailwind': ['tailwind', 'tailwindcss'],
        'Discord': ['discord.js', 'discord bot'],
        'Slack': ['slack api', 'slack bot'],
        'Vite': ['vite'],
        'Playwright': ['playwright'],
    }

    # Difficulty mapping based on template complexity
    _difficulty_map = {
        'simple-todo-app': 'beginner', 'static-landing-page': 'beginner',
        'cli-tool': 'beginner', 'rest-api': 'beginner',
        'blog-platform': 'intermediate', 'full-stack-demo': 'intermediate',
        'rest-api-auth': 'intermediate', 'discord-bot': 'intermediate',
        'slack-bot': 'intermediate', 'ai-chatbot': 'intermediate',
        'npm-library': 'intermediate', 'web-scraper': 'intermediate',
        'api-only': 'intermediate', 'chrome-extension': 'intermediate',
        'game': 'intermediate', 'dashboard': 'intermediate',
        'e-commerce': 'advanced', 'data-pipeline': 'advanced',
        'microservice': 'advanced', 'saas-starter': 'advanced',
        'mobile-app': 'advanced',
    }

    # Build time estimates
    _build_time_map = {
        'simple-todo-app': '3-5 min', 'static-landing-page': '2-4 min',
        'cli-tool': '3-5 min', 'rest-api': '5-8 min',
        'blog-platform': '8-12 min', 'full-stack-demo': '8-12 min',
        'rest-api-auth': '8-12 min', 'discord-bot': '5-8 min',
        'slack-bot': '5-8 min', 'ai-chatbot': '10-15 min',
        'npm-library': '5-8 min', 'web-scraper': '5-8 min',
        'api-only': '5-8 min', 'chrome-extension': '8-12 min',
        'game': '10-15 min', 'dashboard': '10-15 min',
        'e-commerce': '15-20 min', 'data-pipeline': '10-15 min',
        'microservice': '10-15 min', 'saas-starter': '15-20 min',
        'mobile-app': '15-20 min',
    }

    # Category gradients for visual preview
    _gradient_map = {
        'Website': 'from-violet-500/20 via-purple-500/10 to-indigo-500/20',
        'API': 'from-emerald-500/20 via-teal-500/10 to-cyan-500/20',
        'CLI': 'from-amber-500/20 via-orange-500/10 to-yellow-500/20',
        'Bot': 'from-blue-500/20 via-sky-500/10 to-cyan-500/20',
        'Data': 'from-rose-500/20 via-pink-500/10 to-fuchsia-500/20',
        'Other': 'from-slate-500/20 via-gray-500/10 to-zinc-500/20',
    }

    templates = []
    for f in sorted(templates_dir.glob("*.md")):
        name = f.stem.replace("-", " ").replace("_", " ").title()
        description = ""
        tech_stack = []
        try:
            text = f.read_text(errors="replace")
            text_lower = text.lower()
            # Extract description: first non-heading, non-blank paragraph
            for line in text.splitlines():
                stripped = line.strip()
                if not stripped or stripped.startswith("#"):
                    continue
                description = stripped[:200]
                break
            # Detect tech stack from content
            for tech_name, keywords in _tech_keywords.items():
                if any(kw in text_lower for kw in keywords):
                    tech_stack.append(tech_name)
        except OSError:
            pass

        category = _category_map.get(f.stem, "Other")
        templates.append({
            "name": name,
            "filename": f.name,
            "description": description,
            "category": category,
            "tech_stack": tech_stack[:6],  # Limit to 6 techs
            # None for a template the hand-written maps do not cover: the UI
            # shows nothing rather than an invented "intermediate" / "5-10 min".
            "difficulty": _difficulty_map.get(f.stem),
            "build_time": _build_time_map.get(f.stem),
            "gradient": _gradient_map.get(category, _gradient_map["Other"]),
        })
    return JSONResponse(content=templates)


@app.get("/api/templates/{filename}")
async def get_template_content(filename: str) -> JSONResponse:
    """Get a specific template's content."""
    templates_dir = PROJECT_ROOT / "templates"
    resolved = _safe_resolve(templates_dir, filename)
    if resolved is None or not resolved.is_file():
        return JSONResponse(status_code=404, content={"error": "Template not found"})

    try:
        content = resolved.read_text()
    except OSError:
        return JSONResponse(status_code=500, content={"error": "Cannot read template"})

    return JSONResponse(content={"name": filename, "content": content})


# ---------------------------------------------------------------------------
# New GTM endpoints: plan, report, share, provider, metrics, history, onboard
# ---------------------------------------------------------------------------

def _find_loki_cli() -> Optional[str]:
    """Locate the loki CLI binary reliably."""
    import shutil
    # 1. Known project-local path
    if LOKI_CLI.exists():
        return str(LOKI_CLI)
    # 2. shutil.which on PATH
    found = shutil.which("loki")
    if found:
        return found
    return None


def _run_loki_cmd(args: list, cwd: Optional[str] = None, timeout: int = 60) -> tuple[int, str]:
    """Run a loki CLI command and return (returncode, combined output).

    Uses list form -- never shell=True with user input.
    On timeout, the subprocess is explicitly killed to avoid orphaned processes.
    """
    loki = _find_loki_cli()
    if loki is None:
        return (1, "loki CLI not found")
    full_cmd = [loki] + args
    try:
        proc = subprocess.Popen(
            full_cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            text=True,
            cwd=cwd or session.project_dir or str(Path.home()),
            env={**os.environ},
        )
        try:
            stdout, _ = proc.communicate(timeout=timeout)
            return (proc.returncode, stdout or "")
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()
            return (1, "Command timed out")
    except Exception as e:
        return (1, str(e))


@app.post("/api/session/plan")
async def plan_session(req: PlanRequest) -> JSONResponse:
    """Run loki plan dry-run analysis and return structured result."""
    if len(req.prd.encode()) > _MAX_PRD_BYTES:
        return JSONResponse(status_code=400, content={"error": "PRD exceeds 1 MB limit"})
    import tempfile
    with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
        f.write(req.prd)
        prd_tmp = f.name
    try:
        rc, output = await asyncio.get_running_loop().run_in_executor(
            None, lambda: _run_loki_cmd(["plan", prd_tmp], timeout=90)
        )
    finally:
        try:
            os.unlink(prd_tmp)
        except OSError:
            pass

    # Try to parse structured JSON from output first (loki plan may emit JSON blocks)
    _log = logging.getLogger("purple-lab.plan")

    # Anything the plan output does not state stays None: the modal shows
    # "--", never a "standard" / 5-iteration estimate nobody produced.
    complexity = None
    cost_estimate = "unknown"
    iterations = None
    phases: list[str] = []
    parsed = False

    # Look for any JSON object containing plan-related keys (supports nested braces)
    json_match = re.search(r'\{[^{}]*"complexity"[^{}]*\}', output, re.DOTALL)
    if not json_match:
        json_match = re.search(r'\{[^{}]*"iterations"[^{}]*\}', output, re.DOTALL)
    if json_match:
        try:
            data = json.loads(json_match.group(0))
            if isinstance(data.get("complexity"), dict):
                complexity = data["complexity"].get("tier")
            elif isinstance(data.get("complexity"), str):
                complexity = data["complexity"]
            if isinstance(data.get("cost"), dict):
                total = data["cost"].get("total_usd") or data["cost"].get("total", 0)
                try:
                    cost_estimate = f"${float(total):.2f}"
                except (ValueError, TypeError):
                    _log.warning("Could not parse cost value: %r", total)
            elif isinstance(data.get("cost_estimate"), str):
                cost_estimate = data["cost_estimate"]
            if isinstance(data.get("iterations"), dict):
                iterations = data["iterations"].get("estimated")
            elif isinstance(data.get("iterations"), (int, float)):
                iterations = int(data["iterations"])
            if isinstance(data.get("execution_plan"), list):
                phases = [p.get("focus", "") for p in data["execution_plan"] if isinstance(p, dict) and p.get("focus")]
            elif isinstance(data.get("phases"), list):
                phases = [p for p in data["phases"] if isinstance(p, str)]
            parsed = True
        except (json.JSONDecodeError, TypeError, KeyError) as exc:
            _log.warning("JSON plan block found but failed to parse: %s", exc)

    # Fallback: line-by-line text parsing with tighter patterns
    if not parsed:
        _log.info("No JSON plan block found, falling back to text parsing")
        for line in output.splitlines():
            stripped = re.sub(r'\x1b\[[0-9;]*m', '', line)  # strip ANSI codes
            lower = stripped.lower().strip()
            if not lower:
                continue
            # Complexity detection: match "complexity: standard" or "Complexity Tier: complex" etc.
            if re.search(r'complexity\s*(?:tier)?\s*[:=]', lower):
                for val in ("simple", "standard", "complex", "expert"):
                    if re.search(rf'\b{val}\b', lower):
                        complexity = val
                        break
            # Cost parsing: look for dollar amounts in cost/estimate lines
            if ("cost" in lower or "estimate" in lower) and "$" in stripped:
                m = re.search(r"\$[\d,]+\.?\d*", stripped)
                if m:
                    cost_estimate = m.group(0)
            # Iteration count
            if re.search(r'iterations?\s*[:=]\s*\d+', lower):
                m = re.search(r'iterations?\s*[:=]\s*(\d+)', lower)
                if m:
                    iterations = int(m.group(1))
            # Phase/step lines
            if re.match(r'^\s*(phase|step)\s+\d', lower):
                for phase_name in ("planning", "implementation", "testing", "review", "deployment"):
                    if re.search(rf'\b{phase_name}\b', lower) and phase_name not in phases:
                        phases.append(phase_name)

    if not parsed and not phases:
        _log.info("Plan parse produced no phases from output (%d chars)", len(output))
    parsed = parsed or complexity is not None or iterations is not None \
        or bool(phases) or cost_estimate != "unknown"

    return JSONResponse(content={
        "complexity": complexity,
        "cost_estimate": cost_estimate,
        "iterations": iterations,
        "phases": phases,
        # False when nothing in the output was recognised; the modal then
        # shows the raw output instead of an estimate.
        "parsed": parsed,
        "output_text": output,
        "returncode": rc,
    })


@app.post("/api/session/report")
async def generate_report(req: ReportRequest) -> JSONResponse:
    """Run loki report and return content."""
    fmt = req.format if req.format in ("html", "markdown") else "markdown"
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["report", "--format", fmt], timeout=60)
    )
    return JSONResponse(content={
        "content": output,
        "format": fmt,
        "returncode": rc,
    })


@app.post("/api/session/share")
async def share_session() -> JSONResponse:
    """Run loki share and return Gist URL."""
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["share"], timeout=60)
    )
    # Try to extract URL from output
    url_match = re.search(r"https://gist\.github\.com/\S+", output)
    url = url_match.group(0) if url_match else ""
    return JSONResponse(content={
        "url": url,
        "output": output,
        "returncode": rc,
    })


@app.get("/api/provider/current")
async def get_provider() -> JSONResponse:
    """Return current provider and model from session state or config."""
    provider = session.provider or os.environ.get("LOKI_PROVIDER", "claude")
    # Try to read from config
    config_file = Path.home() / ".loki" / "config.json"
    model = ""
    if config_file.exists():
        try:
            with open(config_file) as f:
                cfg = json.load(f)
            provider = cfg.get("provider", provider)
            model = cfg.get("model", model)
        except (json.JSONDecodeError, OSError):
            pass
    return JSONResponse(content={"provider": provider, "model": model})


@app.post("/api/provider/set")
async def set_provider(req: ProviderSetRequest) -> JSONResponse:
    """Set the default provider for future sessions."""
    allowed = {"claude", "codex", "gemini"}
    if req.provider not in allowed:
        return JSONResponse(
            status_code=400,
            content={"error": f"Invalid provider. Must be one of: {', '.join(sorted(allowed))}"},
        )
    # Persist to config
    config_dir = Path.home() / ".loki"
    config_dir.mkdir(parents=True, exist_ok=True)
    config_file = config_dir / "config.json"
    cfg: dict = {}
    if config_file.exists():
        try:
            with open(config_file) as f:
                cfg = json.load(f)
        except (json.JSONDecodeError, OSError):
            cfg = {}
    cfg["provider"] = req.provider
    with open(config_file, "w") as f:
        json.dump(cfg, f, indent=2)
    # Update session state if not running
    if not session.running:
        session.provider = req.provider
    return JSONResponse(content={"provider": req.provider, "set": True})


@app.get("/api/session/metrics")
async def get_metrics() -> JSONResponse:
    """Run loki metrics --json and return parsed output."""
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["metrics", "--json"], timeout=30)
    )
    # Try JSON parse
    try:
        data = json.loads(output)
        return JSONResponse(content=data)
    except (json.JSONDecodeError, ValueError):
        pass
    # Fallback: parse key metrics from text output. A metric the text does not
    # carry stays null, so the panel says "N/A" instead of a measured 0.
    metrics: dict = {
        "iterations": None,
        "quality_gate_pass_rate": None,
        "time_elapsed": "",
        "tokens_used": None,
        "output_text": output,
    }
    for line in output.splitlines():
        if "iteration" in line.lower():
            m = re.search(r"(\d+)", line)
            if m:
                metrics["iterations"] = int(m.group(1))
        if "pass rate" in line.lower() or "pass_rate" in line.lower():
            m = re.search(r"([\d.]+)%?", line)
            if m:
                metrics["quality_gate_pass_rate"] = float(m.group(1))
        if "token" in line.lower():
            m = re.search(r"(\d+)", line)
            if m:
                metrics["tokens_used"] = int(m.group(1))
    return JSONResponse(content=metrics)


def _infer_session_status(entry: Path) -> str:
    """Infer session status from project directory contents.

    Returns "unknown" (rather than guessing "completed") whenever the only
    evidence is a state file that has simply gone stale, or a directory that
    merely contains a source file -- neither is a clean-exit marker, and both
    used to be misreported as "completed".
    """
    # Phase/status strings that describe a run no longer in flight. Anything
    # else (a RARV step, "running", a custom in-progress label, ...) is only
    # trustworthy while the state file is fresh -- once it goes stale we no
    # longer know if the process is still alive, so we must not keep
    # reporting it as active, and we must not guess "completed" either.
    _TERMINAL_STATUSES = {"completed", "failed", "paused", "stopped", "cancelled"}

    # Set when dashboard-state.json had a non-idle phase we could not trust
    # outright (stale and not a terminal phase), so a directory-contents
    # guess below must not silently overrule it either.
    saw_untrusted_activity = False

    # 1. Check .loki/dashboard-state.json for explicit phase
    # BUG-INT-002 fix: CLI writes dashboard-state.json, not state/session.json
    state_file = entry / ".loki" / "dashboard-state.json"
    if state_file.exists():
        try:
            with open(state_file) as f:
                st = json.load(f)
            phase = st.get("phase", "")
            if phase and phase != "idle":
                if phase.lower() in _TERMINAL_STATUSES:
                    return phase
                # Active-looking phase: only trustworthy while fresh. Staleness
                # means "we can't tell if this is still running" -- it is not
                # evidence the run finished cleanly, so fall through instead
                # of returning or guessing "completed".
                try:
                    mtime = state_file.stat().st_mtime
                    if time.time() - mtime <= 300:  # fresh enough to trust
                        return phase
                except OSError:
                    return phase
                saw_untrusted_activity = True
        except (json.JSONDecodeError, OSError):
            pass

    # 2. Check .loki/autonomy-state.json (run.sh writes this)
    for state_name in ("autonomy-state.json", ".loki/autonomy-state.json"):
        sf = entry / state_name
        if sf.exists():
            try:
                with open(sf) as f:
                    st = json.load(f)
                if st.get("completed") or st.get("status") == "completed":
                    return "completed"
                if st.get("status"):
                    status_val = st["status"]
                    if status_val.lower() in _TERMINAL_STATUSES:
                        return status_val
                    # Active-looking status: only trustworthy while fresh.
                    # A stale one is unknown, not completed -- the process may
                    # have died mid-run without recording why.
                    try:
                        mtime = sf.stat().st_mtime
                        if time.time() - mtime > 300:  # 5 minutes stale
                            return "unknown"
                    except OSError:
                        pass
                    return status_val
            except (json.JSONDecodeError, OSError):
                pass

    # Neither state file gave a definitive, still-trustworthy answer, but one
    # of them showed activity we couldn't corroborate. We know *something*
    # was in flight, not whether it finished -- that is "unknown", not a
    # guess drawn from directory contents.
    if saw_untrusted_activity:
        return "unknown"

    # 3. Infer from file contents
    files = set()
    try:
        files = {f.name for f in entry.iterdir() if not f.name.startswith(".")}
    except OSError:
        pass

    source_extensions = {".js", ".ts", ".tsx", ".py", ".html", ".css", ".go", ".rs", ".java", ".rb"}
    has_source = any(
        (entry / f).suffix in source_extensions
        for f in files
        if (entry / f).is_file()
    )
    has_prd = "PRD.md" in files or "prd.md" in files

    if has_source:
        # A source file is not a completion marker -- it says nothing about
        # whether the run finished, failed, or is still in progress.
        return "unknown"
    if has_prd and len(files) <= 2:
        return "started"
    if has_prd:
        return "in_progress"

    return "empty"


@app.get("/api/sessions/history")
async def get_sessions_history() -> JSONResponse:
    """Return list of past loki sessions from ~/purple-lab-projects/ etc."""
    history: list[dict] = []
    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    for base_dir in search_dirs:
        if not base_dir.is_dir():
            continue
        for entry in sorted(base_dir.iterdir(), reverse=True)[:20]:
            if not entry.is_dir():
                continue
            session_info: dict = {
                "id": entry.name,
                "path": f"~/purple-lab-projects/{entry.name}" if "purple-lab" in str(base_dir) else f"~/.loki/sessions/{entry.name}",
                "date": "",
                "prd_snippet": "",
                "status": _infer_session_status(entry),
            }
            # Read timestamp from directory mtime
            try:
                mtime = entry.stat().st_mtime
                session_info["date"] = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
            except OSError:
                pass
            # Try to read PRD
            for prd_name in ("PRD.md", "prd.md", ".loki/prd.md"):
                prd_file = entry / prd_name
                if prd_file.exists():
                    try:
                        text = prd_file.read_text(errors="replace")
                        lines = [l.strip() for l in text.splitlines() if l.strip()]
                        session_info["prd_snippet"] = lines[0][:120] if lines else ""
                    except OSError:
                        pass
                    break
            # Count project files for progress indication
            try:
                file_count = sum(1 for f in entry.rglob("*") if f.is_file()
                                 and ".git" not in f.parts and "node_modules" not in f.parts
                                 and "__pycache__" not in f.parts)
                session_info["file_count"] = file_count
            except OSError:
                session_info["file_count"] = 0

            history.append(session_info)
    return JSONResponse(content=history)


@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str) -> JSONResponse:
    """Delete a session and all its files, processes, and state."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})

    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # Prevent deleting the currently active session directory
    if session.project_dir and Path(session.project_dir).resolve() == target.resolve():
        return JSONResponse(status_code=409, content={"error": "Cannot delete the currently active session. Stop it first."})

    # 1. Stop Docker containers for this project (before stopping dev server)
    try:
        for compose_file in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
            if (target / compose_file).exists():
                subprocess.run(
                    ["docker", "compose", "-f", str(target / compose_file), "down", "--volumes", "--remove-orphans"],
                    cwd=str(target), capture_output=True, timeout=30,
                )
                break
    except Exception:
        pass

    # 2. Stop dev server if running
    if session_id in dev_server_manager.servers:
        await dev_server_manager.stop(session_id)

    # 4. Stop file watcher if running
    file_watcher.stop(session_id)

    # 5. Close terminal PTY if open
    pty = _terminal_ptys.get(session_id)
    if pty:
        try:
            pty.close()
        except Exception:
            pass
        _terminal_ptys.pop(session_id, None)

    # 6. Delete project directory (including node_modules, .loki, everything)
    import shutil
    try:
        shutil.rmtree(str(target))
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": f"Failed to delete: {e}"})

    return JSONResponse(content={"deleted": True, "session_id": session_id})


@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str) -> JSONResponse:
    """Get details of a past session for read-only viewing."""
    # Validate session_id format (prevent path traversal)
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # Read PRD
    prd_content = ""
    for prd_name in ("PRD.md", "prd.md", ".loki/prd.md"):
        prd_file = target / prd_name
        if prd_file.exists():
            try:
                prd_content = prd_file.read_text(errors="replace")
            except OSError:
                pass
            break

    # Build file tree
    files = _build_file_tree(target)

    # Read logs if available
    log_lines: list[str] = []
    for log_name in (".loki/logs/session.log", ".loki/session.log", "loki.log"):
        log_file = target / log_name
        if log_file.exists():
            try:
                text = log_file.read_text(errors="replace")
                log_lines = text.splitlines()[-200:]
            except OSError:
                pass
            break

    # Status
    status = _infer_session_status(target)

    return JSONResponse(content={
        "id": session_id,
        "path": str(target),
        "status": status,
        "prd": prd_content,
        "files": files,
        "logs": log_lines,
    })


@app.get("/api/sessions/{session_id}/file")
async def get_session_file(session_id: str, path: str = "") -> JSONResponse:
    """Get file content from a past session with path traversal protection."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id) or not path:
        return JSONResponse(status_code=400, content={"error": "Invalid session ID or path"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    base = target.resolve()
    resolved = _safe_resolve(base, path)
    if resolved is None or not resolved.is_file():
        return JSONResponse(status_code=404, content={"error": "File not found"})

    try:
        size = resolved.stat().st_size
        if size > 1_048_576:
            return JSONResponse(content={"content": f"[File too large: {size:,} bytes]"})
        content = resolved.read_text(errors="replace")
    except (OSError, UnicodeDecodeError) as e:
        return JSONResponse(content={"content": f"[Cannot read file: {e}]"})

    return JSONResponse(content={"content": content})


@app.get("/api/sessions/{session_id}/preview/{file_path:path}")
async def preview_session_file(session_id: str, file_path: str = "index.html") -> FileResponse:
    """Serve a file from a past session's project directory with correct MIME type.

    This enables live preview of built projects -- HTML files can load their
    relative CSS, JS, and image assets correctly.
    """
    import mimetypes
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    if not file_path:
        file_path = "index.html"

    resolved = _safe_resolve(target, file_path)
    if resolved is None or not resolved.is_file():
        return JSONResponse(status_code=404, content={"error": "File not found"})

    # Determine MIME type
    mime_type, _ = mimetypes.guess_type(str(resolved))
    if mime_type is None:
        mime_type = "application/octet-stream"

    return FileResponse(str(resolved), media_type=mime_type)


@app.put("/api/sessions/{session_id}/file")
async def save_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
    """Save or update file content in a session's project directory."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not req.path:
        return JSONResponse(status_code=400, content={"error": "Path is required"})
    if len(req.content.encode("utf-8", errors="replace")) > 1_048_576:
        return JSONResponse(status_code=413, content={"error": "Content exceeds 1 MB limit"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    base = target.resolve()
    resolved = _safe_resolve(base, req.path)
    if resolved is None:
        return JSONResponse(status_code=400, content={"error": "Invalid path (traversal blocked)"})

    # Atomic write: write to .tmp then rename
    tmp_path = resolved.with_suffix(resolved.suffix + ".tmp")
    try:
        tmp_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path.write_text(req.content, encoding="utf-8")
        tmp_path.rename(resolved)
    except OSError as e:
        # Clean up temp file on failure
        try:
            tmp_path.unlink(missing_ok=True)
        except OSError:
            pass
        return JSONResponse(status_code=500, content={"error": f"Failed to write file: {e}"})

    return JSONResponse(content={"saved": True, "path": req.path})


@app.post("/api/sessions/{session_id}/file")
async def create_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
    """Create a new file in a session's project directory."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not req.path:
        return JSONResponse(status_code=400, content={"error": "Path is required"})
    if len(req.content.encode("utf-8", errors="replace")) > 1_048_576:
        return JSONResponse(status_code=413, content={"error": "Content exceeds 1 MB limit"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    base = target.resolve()
    resolved = _safe_resolve(base, req.path)
    if resolved is None:
        return JSONResponse(status_code=400, content={"error": "Invalid path (traversal blocked)"})

    if resolved.exists():
        return JSONResponse(status_code=409, content={"error": "File already exists"})

    try:
        resolved.parent.mkdir(parents=True, exist_ok=True)
        resolved.write_text(req.content, encoding="utf-8")
    except OSError as e:
        return JSONResponse(status_code=500, content={"error": f"Failed to create file: {e}"})

    return JSONResponse(content={"created": True, "path": req.path})


@app.delete("/api/sessions/{session_id}/file")
async def delete_session_file(session_id: str, req: FileDeleteRequest) -> JSONResponse:
    """Delete a file from a session's project directory."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not req.path:
        return JSONResponse(status_code=400, content={"error": "Path is required"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    base = target.resolve()
    resolved = _safe_resolve(base, req.path)
    if resolved is None:
        return JSONResponse(status_code=400, content={"error": "Invalid path (traversal blocked)"})

    if not resolved.exists():
        return JSONResponse(status_code=404, content={"error": "File not found"})

    if resolved.is_dir():
        return JSONResponse(status_code=400, content={"error": "Cannot delete directories, only files"})

    try:
        resolved.unlink()
    except OSError as e:
        return JSONResponse(status_code=500, content={"error": f"Failed to delete file: {e}"})

    return JSONResponse(content={"deleted": True, "path": req.path})


@app.post("/api/sessions/{session_id}/directory")
async def create_session_directory(session_id: str, req: DirectoryCreateRequest) -> JSONResponse:
    """Create a directory in a session's project directory."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not req.path:
        return JSONResponse(status_code=400, content={"error": "Path is required"})

    search_dirs = [
        Path.home() / "purple-lab-projects",
        Path.home() / ".loki-sessions",
        Path.home() / ".loki" / "sessions",
    ]
    target: Optional[Path] = None
    for base_dir in search_dirs:
        candidate = base_dir / session_id
        if candidate.is_dir():
            target = candidate
            break

    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    base = target.resolve()
    resolved = _safe_resolve(base, req.path)
    if resolved is None:
        return JSONResponse(status_code=400, content={"error": "Invalid path (traversal blocked)"})

    if resolved.exists():
        return JSONResponse(status_code=409, content={"error": "Path already exists"})

    try:
        resolved.mkdir(parents=True, exist_ok=False)
    except FileExistsError:
        return JSONResponse(status_code=409, content={"error": "Directory already exists"})
    except OSError as e:
        return JSONResponse(status_code=500, content={"error": f"Failed to create directory: {e}"})

    return JSONResponse(content={"created": True, "path": req.path})


@app.post("/api/session/onboard")
async def onboard_session(req: OnboardRequest) -> JSONResponse:
    """Run loki onboard on a path and return CLAUDE.md content."""
    # Path traversal protection: must be absolute, exist, and within home directory
    try:
        target = Path(req.path).resolve()
    except (ValueError, OSError):
        return JSONResponse(status_code=400, content={"error": "Invalid path"})
    home = Path.home().resolve()
    try:
        target.relative_to(home)
    except ValueError:
        return JSONResponse(status_code=400, content={"error": "Path must be within your home directory"})
    if not target.exists():
        return JSONResponse(status_code=400, content={"error": "Path does not exist"})
    if not target.is_dir():
        return JSONResponse(status_code=400, content={"error": "Path must be a directory"})

    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["onboard", str(target)], cwd=str(target), timeout=120)
    )
    # Try to read generated CLAUDE.md
    claude_md = target / "CLAUDE.md"
    claude_content = ""
    if claude_md.exists():
        try:
            claude_content = claude_md.read_text(errors="replace")
        except OSError:
            pass
    return JSONResponse(content={
        "output": output,
        "claude_md": claude_content,
        "returncode": rc,
    })


# ---------------------------------------------------------------------------
# CLI feature endpoints (chat, review, test, explain, export)
# ---------------------------------------------------------------------------


@app.post("/api/sessions/{session_id}/chat")
async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
    """Start a chat command (non-blocking). Returns task_id for polling."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # Clean up old completed tasks to prevent unbounded memory growth
    _cleanup_chat_tasks()
    task = ChatTask()
    _chat_tasks[task.id] = task

    async def run_chat() -> None:
        proc: Optional[subprocess.Popen] = None
        loki = _find_loki_cli()
        if loki is None:
            task.output_lines = ["loki CLI not found"]
            task.returncode = 1
            task.complete = True
            return
        # All chat modes use 'loki quick' with the user's message as the task.
        # This runs claude (or configured provider) in the project directory
        # to make changes based on the user's prompt -- works both during
        # active sessions and post-completion for iterative development.

        # -- Phase 1: Inject project context (dev server errors, gate failures) --
        context_parts = []

        # BUG-E2E-004: Inject recent chat history so AI has conversation context
        if req.history:
            # Include last 5 exchanges max to avoid token bloat
            recent = req.history[-10:]  # last 10 items = ~5 exchanges
            history_lines = []
            for entry in recent:
                role = entry.get("role", "user")
                content = entry.get("content", "")
                if content and role in ("user", "assistant"):
                    # Truncate long assistant responses to keep context manageable
                    if role == "assistant" and len(content) > 500:
                        content = content[:500] + "..."
                    history_lines.append(f"[{role.upper()}]: {content}")
            if history_lines:
                context_parts.append(
                    "PREVIOUS CONVERSATION CONTEXT:\n" + "\n".join(history_lines)
                    + "\n\nNow handle the following new request:"
                )

        context_parts.append(req.message)

        # Inject dev server errors if the server has crashed
        ds_info = dev_server_manager.servers.get(session_id)
        if ds_info and ds_info.get("status") == "error":
            error_lines = ds_info.get("output_lines", [])[-30:]
            if error_lines:
                context_parts.append(
                    "\n\nDEV SERVER ERROR (fix this):\n" + "\n".join(error_lines)
                )

        # Phase 1.5: Inject Docker Compose context
        ds_info_docker = dev_server_manager.servers.get(session_id)
        if ds_info_docker and ds_info_docker.get("framework") == "docker":
            try:
                docker_ctx = await _gather_docker_context(target)
                if docker_ctx.get("failing_services"):
                    context_parts.append("\n\nDOCKER SERVICE STATUS:\n" + json.dumps(docker_ctx["service_status"], indent=2))
                    for svc_name, svc_logs in docker_ctx.get("service_logs", {}).items():
                        if svc_name != "_combined":
                            context_parts.append(f"\n\nFAILING SERVICE '{svc_name}' LOGS:\n{svc_logs}")
                    diagnoses = _diagnose_errors("\n".join(docker_ctx.get("service_logs", {}).values()))
                    if diagnoses:
                        context_parts.append("\n\nAUTO-DIAGNOSIS:\n" + "\n".join(
                            f"- {d['diagnosis']}: {d['suggestion']}" for d in diagnoses))
                if docker_ctx.get("project_structure"):
                    context_parts.append("\n\nPROJECT FILES:\n" + docker_ctx["project_structure"])
            except Exception:
                logger.debug("Docker context gathering failed", exc_info=True)

        # Inject quality gate failures if any
        gate_file = target / ".loki" / "quality" / "gate-failures.txt"
        if gate_file.exists():
            try:
                gate_text = gate_file.read_text()[:2000]
                if gate_text.strip():
                    context_parts.append(
                        "\n\nQUALITY GATE FAILURES:\n" + gate_text
                    )
            except OSError:
                pass

        full_message = "\n".join(context_parts)

        # Inject Docker Compose requirement into prompts so generated projects
        # always include a docker-compose.yml for containerized execution.
        docker_note = " (IMPORTANT: include a Dockerfile and docker-compose.yml so the app runs in a container via 'docker compose up')"
        docker_instructions_file = SCRIPT_DIR / "docker-instructions.md"
        docker_extra = ""
        if docker_instructions_file.exists():
            try:
                docker_extra = "\n\n" + docker_instructions_file.read_text()
            except OSError:
                pass
        # Determine the active provider for this session
        chat_provider = session.provider or "claude"
        # Also check .loki/state/provider file in the session directory
        provider_file = target / ".loki" / "state" / "provider"
        if provider_file.exists():
            try:
                saved_prov = provider_file.read_text().strip()
                if saved_prov:
                    chat_provider = saved_prov
            except OSError:
                pass

        if req.mode == "max":
            # Max mode: full loki start with the message as a PRD
            prd_path = target / ".loki" / "chat-prd.md"
            prd_path.parent.mkdir(parents=True, exist_ok=True)
            prd_content = full_message + "\n\n## Deployment\nMUST include Dockerfile and docker-compose.yml for containerized execution." + docker_extra
            prd_path.write_text(prd_content)
            # BUG-INT-005 fix: use session's configured provider, not hardcoded "claude"
            cmd_args = [loki, "start", "--provider", chat_provider, str(prd_path)]
        else:
            # Quick and Standard both use 'loki quick' -- fast, focused changes
            cmd_args = [loki, "quick", full_message + docker_note]
        try:
            chat_env = {**os.environ}
            chat_env.update(_load_secrets())
            # Pass provider via env for quick mode (loki quick uses LOKI_PROVIDER env)
            chat_env["LOKI_PROVIDER"] = chat_provider
            # loki is resolved from the trusted application install; message,
            # provider, and PRD path remain argv data with no command shell.
            proc = subprocess.Popen(
                cmd_args,  # lgtm[py/command-line-injection]
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=str(target),
                env=chat_env,
                start_new_session=True,
                shell=False,
            )
            task.process = proc
            _track_child_pid(proc.pid)
            loop = asyncio.get_running_loop()

            def _read_lines() -> None:
                """Read stdout line-by-line in a thread."""
                assert proc.stdout is not None
                for raw_line in proc.stdout:
                    if task.cancelled:
                        break
                    # Strip ANSI escape codes for clean display
                    clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
                    # Filter out noisy tool-use output lines from Claude
                    stripped = clean.strip()
                    if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
                                    "[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
                                    "[Result]", "[Thinking]"):
                        continue
                    if not stripped:
                        continue
                    # Skip npm noise lines
                    if any(noise in stripped for noise in ("npm warn", "npm notice", "npm WARN")):
                        continue
                    # Categorize file change lines for structured frontend display
                    if (stripped.startswith("Created ") or stripped.startswith("Modified ") or
                            stripped.startswith("Deleted ") or stripped.startswith("Wrote ")):
                        task.output_lines.append(f"__FILE_CHANGE__{clean}")
                    elif (stripped.startswith("$ ") or stripped.startswith("Running: ")):
                        task.output_lines.append(f"__COMMAND__{clean}")
                    else:
                        task.output_lines.append(clean)
                proc.stdout.close()

            await asyncio.wait_for(
                loop.run_in_executor(None, _read_lines),
                timeout=300,
            )
            proc.wait(timeout=10)
            task.returncode = proc.returncode
        except asyncio.TimeoutError:
            if proc is not None:
                try:
                    pgid = os.getpgid(proc.pid)
                    os.killpg(pgid, signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    proc.kill()
                proc.wait()
            task.output_lines.append("Command timed out after 5 minutes")
            task.returncode = 1
        except Exception as e:
            task.output_lines.append(str(e))
            task.returncode = 1
            if proc is not None and proc.poll() is None:
                try:
                    pgid = os.getpgid(proc.pid)
                    os.killpg(pgid, signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    proc.kill()
                proc.wait()
        # Detect changed files (both committed and uncommitted)
        try:
            changed = set()
            # Check uncommitted changes (working tree + staged)
            r1 = subprocess.run(
                ["git", "diff", "--name-only"],
                cwd=str(target), capture_output=True, text=True, timeout=10,
            )
            if r1.returncode == 0:
                changed.update(f for f in r1.stdout.strip().splitlines() if f)
            # Check staged changes
            r2 = subprocess.run(
                ["git", "diff", "--name-only", "--cached"],
                cwd=str(target), capture_output=True, text=True, timeout=10,
            )
            if r2.returncode == 0:
                changed.update(f for f in r2.stdout.strip().splitlines() if f)
            # Check recent commit if any
            r3 = subprocess.run(
                ["git", "diff", "--name-only", "HEAD~1"],
                cwd=str(target), capture_output=True, text=True, timeout=10,
            )
            if r3.returncode == 0:
                changed.update(f for f in r3.stdout.strip().splitlines() if f)
            task.files_changed = sorted(changed)
        except Exception:
            pass
        # Untrack the child PID now that the chat process is done
        if proc is not None:
            _untrack_child_pid(proc.pid)
        task.complete = True

    asyncio.create_task(run_chat())

    return JSONResponse(content={
        "task_id": task.id,
        "status": "running",
    })


@app.get("/api/sessions/{session_id}/chat/{task_id}")
async def get_chat_status(session_id: str, task_id: str) -> JSONResponse:
    """Poll chat task status and get partial output."""
    task = _chat_tasks.get(task_id)
    if task is None:
        return JSONResponse(status_code=404, content={"error": "Task not found"})
    return JSONResponse(content={
        "task_id": task.id,
        "status": "complete" if task.complete else "running",
        "output_lines": task.output_lines,
        "returncode": task.returncode,
        "files_changed": task.files_changed,
        "complete": task.complete,
    })


@app.get("/api/sessions/{session_id}/chat/{task_id}/stream")
async def stream_chat(session_id: str, task_id: str, request: Request) -> StreamingResponse:
    """Stream chat task output as Server-Sent Events.

    Sends incremental output lines as they arrive, 10x faster than polling.
    Falls back gracefully -- the polling endpoint remains available.
    """
    task = _chat_tasks.get(task_id)
    _sse_headers = {
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        "X-Accel-Buffering": "no",
    }
    if task is None:
        async def _not_found():
            yield f"event: error\ndata: {json.dumps({'error': 'Task not found'})}\n\n"
        return StreamingResponse(_not_found(), media_type="text/event-stream", headers=_sse_headers)

    async def event_generator():
        last_line = 0
        while True:
            # Check if the client disconnected
            if await request.is_disconnected():
                break

            # Send new lines since last check
            current_count = len(task.output_lines)
            if current_count > last_line:
                for line in task.output_lines[last_line:current_count]:
                    yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
                last_line = current_count

            # Check if complete -- flush any final lines first
            if task.complete:
                final_count = len(task.output_lines)
                if final_count > last_line:
                    for line in task.output_lines[last_line:final_count]:
                        yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
                yield f"event: complete\ndata: {json.dumps({'returncode': task.returncode, 'files_changed': task.files_changed})}\n\n"
                break

            await asyncio.sleep(0.1)  # 100ms -- 20x faster than 2s polling

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers=_sse_headers,
    )


@app.post("/api/sessions/{session_id}/chat/{task_id}/cancel")
async def cancel_chat(session_id: str, task_id: str) -> JSONResponse:
    """Cancel a running chat task."""
    task = _chat_tasks.get(task_id)
    if task is None:
        return JSONResponse(status_code=404, content={"error": "Task not found"})
    if task.complete:
        return JSONResponse(content={"cancelled": False, "reason": "Task already complete"})
    task.cancelled = True
    if task.process and task.process.poll() is None:
        try:
            pgid = os.getpgid(task.process.pid)
            os.killpg(pgid, signal.SIGTERM)
            await asyncio.to_thread(task.process.wait, timeout=3)
        except (ProcessLookupError, OSError, subprocess.TimeoutExpired):
            pass
        if task.process.poll() is None:
            try:
                pgid = os.getpgid(task.process.pid)
                os.killpg(pgid, signal.SIGKILL)
            except (ProcessLookupError, OSError):
                task.process.kill()
            try:
                await asyncio.to_thread(task.process.wait, timeout=5)
            except subprocess.TimeoutExpired:
                pass
    task.output_lines.append("[cancelled by user]")
    task.returncode = 1
    task.complete = True
    return JSONResponse(content={"cancelled": True})


@app.post("/api/sessions/{session_id}/fix")
async def fix_session(session_id: str) -> JSONResponse:
    """Run loki quick with dev server error context to auto-fix crashes."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # Gather error context from dev server output
    ds_info = dev_server_manager.servers.get(session_id)
    error_lines: list[str] = []
    if ds_info:
        error_lines = ds_info.get("output_lines", [])[-30:]

    if not error_lines:
        return JSONResponse(status_code=400, content={"error": "No dev server error output to fix"})

    error_context = "\n".join(error_lines)
    fix_message = (
        f"The dev server crashed. Fix the error and ensure the app starts correctly.\n\n"
        f"DEV SERVER ERROR OUTPUT:\n{error_context}"
    )

    # Use the chat endpoint flow -- create a task and return task_id
    _cleanup_chat_tasks()
    task = ChatTask()
    _chat_tasks[task.id] = task

    async def run_fix() -> None:
        loki = _find_loki_cli()
        if loki is None:
            task.output_lines = ["loki CLI not found"]
            task.returncode = 1
            task.complete = True
            return
        proc: Optional[subprocess.Popen] = None
        try:
            fix_env = {**os.environ}
            fix_env.update(_load_secrets())
            # Pass provider via env for fix commands (same as chat)
            fix_provider = session.provider or "claude"
            _fix_prov_file = target / ".loki" / "state" / "provider"
            if _fix_prov_file.exists():
                try:
                    _fp = _fix_prov_file.read_text().strip()
                    if _fp:
                        fix_provider = _fp
                except OSError:
                    pass
            fix_env["LOKI_PROVIDER"] = fix_provider
            proc = subprocess.Popen(
                [loki, "quick", fix_message],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=str(target),
                env=fix_env,
                start_new_session=True,
            )
            task.process = proc
            _track_child_pid(proc.pid)
            loop = asyncio.get_running_loop()

            def _read() -> None:
                assert proc.stdout is not None
                for raw_line in proc.stdout:
                    if task.cancelled:
                        break
                    clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
                    stripped = clean.strip()
                    if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
                                    "[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
                                    "[Result]", "[Thinking]"):
                        continue
                    if not stripped:
                        continue
                    task.output_lines.append(clean)
                proc.stdout.close()

            await asyncio.wait_for(loop.run_in_executor(None, _read), timeout=300)
            proc.wait(timeout=10)
            task.returncode = proc.returncode
        except asyncio.TimeoutError:
            if proc is not None:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    proc.kill()
                proc.wait()
            task.output_lines.append("Fix timed out after 5 minutes")
            task.returncode = 1
        except Exception as e:
            task.output_lines.append(str(e))
            task.returncode = 1
            if proc is not None and proc.poll() is None:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    proc.kill()
                proc.wait()
        # Untrack the child PID now that the fix process is done
        if proc is not None:
            _untrack_child_pid(proc.pid)
        task.complete = True

    asyncio.create_task(run_fix())

    return JSONResponse(content={
        "task_id": task.id,
        "status": "running",
        "error_context": error_context[:500],
    })


@app.post("/api/sessions/{session_id}/review")
async def review_session(session_id: str) -> JSONResponse:
    """Run loki review on a project."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["review", str(target)], cwd=str(target), timeout=120)
    )
    return JSONResponse(content={"output": output, "returncode": rc})


@app.post("/api/sessions/{session_id}/test")
async def test_session(session_id: str) -> JSONResponse:
    """Run loki test on a project."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["test", "--dir", str(target)], cwd=str(target), timeout=120)
    )
    return JSONResponse(content={"output": output, "returncode": rc})


@app.post("/api/sessions/{session_id}/explain")
async def explain_session(session_id: str) -> JSONResponse:
    """Run loki explain on a project."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["explain", str(target)], cwd=str(target), timeout=120)
    )
    return JSONResponse(content={"output": output, "returncode": rc})


@app.post("/api/sessions/{session_id}/export")
async def export_session(session_id: str) -> JSONResponse:
    """Run loki export json on a project."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    rc, output = await asyncio.get_running_loop().run_in_executor(
        None, lambda: _run_loki_cmd(["export", "json"], cwd=str(target), timeout=60)
    )
    return JSONResponse(content={"output": output, "returncode": rc})


# ---------------------------------------------------------------------------
# Secrets management endpoints
# ---------------------------------------------------------------------------


@app.get("/api/secrets")
async def get_secrets() -> JSONResponse:
    """List secret keys (values masked)."""
    secrets = _load_secrets()
    masked = {k: "***" for k in secrets}
    return JSONResponse(content=masked)


@app.post("/api/secrets")
async def set_secret(req: SecretRequest) -> JSONResponse:
    """Set or update a secret."""
    if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', req.key):
        return JSONResponse(status_code=400, content={"error": "Invalid key. Use ENV_VAR style names."})
    secrets = _load_secrets()
    secrets[req.key] = req.value
    _save_secrets(secrets)
    return JSONResponse(content={"set": True, "key": req.key})


@app.delete("/api/secrets/{key}")
async def delete_secret(key: str) -> JSONResponse:
    """Delete a secret."""
    if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', key):
        return JSONResponse(status_code=400, content={"error": "Invalid key format"})
    secrets = _load_secrets()
    if key not in secrets:
        return JSONResponse(status_code=404, content={"error": "Secret not found"})
    del secrets[key]
    _save_secrets(secrets)
    return JSONResponse(content={"deleted": True, "key": key})


# ---------------------------------------------------------------------------
# Preview info (smart project type detection)
# ---------------------------------------------------------------------------


@app.get("/api/sessions/{session_id}/preview-info")
async def get_preview_info(session_id: str) -> JSONResponse:
    """Detect project type and determine the best preview strategy."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    info: dict = {
        "type": "unknown",
        "preview_url": None,
        "entry_file": None,
        "dev_command": None,
        "port": None,
        "description": "No preview available",
    }

    # Detect project type from files (check root and first-level subdirectories)
    files = {f.name for f in target.iterdir() if f.is_file()} if target.is_dir() else set()
    has_package_json = "package.json" in files
    # If package.json not at root, check immediate subdirectories (e.g. project inside a folder)
    project_root = target
    if not has_package_json and target.is_dir():
        for subdir in target.iterdir():
            if subdir.is_dir() and (subdir / "package.json").exists():
                project_root = subdir
                files = {f.name for f in subdir.iterdir() if f.is_file()}
                has_package_json = True
                break
    has_index_html = (
        "index.html" in files
        or (project_root / "public" / "index.html").exists()
        or (project_root / "src" / "index.html").exists()
    )
    has_pyproject = "pyproject.toml" in files or "setup.py" in files or "requirements.txt" in files
    has_go_mod = "go.mod" in files
    has_cargo = "Cargo.toml" in files
    has_dockerfile = "Dockerfile" in files or "docker-compose.yml" in files
    has_pom = "pom.xml" in files
    has_gradle = "build.gradle" in files or "build.gradle.kts" in files
    has_gemfile = "Gemfile" in files
    has_rails_routes = (project_root / "config" / "routes.rb").exists()
    has_artisan = "artisan" in files
    has_mix = "mix.exs" in files
    has_package_swift = "Package.swift" in files

    # Read package.json for more info
    pkg_scripts: dict = {}
    pkg_deps: dict = {}
    if has_package_json:
        try:
            pkg = json.loads((project_root / "package.json").read_text())
            pkg_scripts = pkg.get("scripts", {})
            pkg_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
        except (json.JSONDecodeError, OSError):
            pass

    # Determine project type and preview strategy
    is_expo = "expo" in pkg_deps
    is_react = "react" in pkg_deps or "next" in pkg_deps or "vite" in pkg_deps
    is_express = "express" in pkg_deps or "fastify" in pkg_deps or "koa" in pkg_deps or "hono" in pkg_deps
    is_flask = has_pyproject and any((project_root / f).exists() for f in ["app.py", "main.py", "server.py"])
    is_fastapi = has_pyproject and any(
        "fastapi" in (project_root / f).read_text(errors="replace")
        for f in ["app.py", "main.py", "server.py"]
        if (project_root / f).exists()
    )

    # Docker Compose check -- highest priority (isolated, no port conflicts)
    has_compose = any((project_root / f).exists() for f in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"))

    if has_compose:
        compose_file = next(f for f in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") if (project_root / f).exists())
        info["type"] = "docker"
        info["dev_command"] = f"docker compose -f {compose_file} up --build"
        info["description"] = "Containerized app -- runs via Docker Compose"
        # Use smart service resolution to find the primary port
        compose_port = 3000
        compose_services: list[dict] = []
        primary_service_name: Optional[str] = None
        try:
            import yaml
            with open(project_root / compose_file) as f:
                compose_data = yaml.safe_load(f)
            if compose_data and "services" in compose_data:
                for svc_name, svc in compose_data["services"].items():
                    svc_ports: list[int] = []
                    for p in svc.get("ports", []):
                        p_str = str(p)
                        if ":" in p_str:
                            parts = p_str.split(":")
                            try:
                                svc_ports.append(int(parts[-2].split("-")[0]))
                            except (ValueError, IndexError):
                                continue
                    compose_services.append({
                        "name": svc_name,
                        "ports": svc_ports,
                        "image": svc.get("image"),
                        "has_build": "build" in svc,
                    })
                primary_service_name, compose_port = dev_server_manager._resolve_primary_service(compose_services)
        except ImportError:
            try:
                content = (project_root / compose_file).read_text()
                port_match = re.search(r'"?(\d+):(\d+)"?', content)
                if port_match:
                    compose_port = int(port_match.group(1))
            except Exception:
                pass
        except Exception:
            pass
        info["port"] = compose_port
        if primary_service_name:
            info["primary_service"] = primary_service_name
        if compose_services:
            info["services"] = compose_services
    elif is_expo:
        info["type"] = "expo"
        info["port"] = 8081
        info["dev_command"] = "npx expo start"
        info["description"] = "Expo/React Native app -- scan QR code with Expo Go"
    elif is_react or (has_package_json and has_index_html):
        info["type"] = "web-app"
        info["entry_file"] = "index.html"
        info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
        info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
        info["description"] = "Web application -- serves HTML/CSS/JS"
    elif is_express or (has_package_json and ("start" in pkg_scripts or "dev" in pkg_scripts) and not has_index_html):
        # API/server project
        port = 3000  # default
        # Try to detect port from scripts
        start_script = pkg_scripts.get("start", "") + pkg_scripts.get("dev", "")
        port_match = re.search(r"(?:PORT|port)[=: ]*(\d+)", start_script)
        if port_match:
            port = int(port_match.group(1))
        info["type"] = "api"
        info["port"] = port
        info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
        info["description"] = f"API server -- runs on port {port}"
        # Check for swagger/openapi
        for swagger_path in ["swagger.json", "openapi.json", "docs", "api-docs"]:
            if (target / swagger_path).exists():
                info["preview_url"] = f"/api/sessions/{session_id}/preview/{swagger_path}"
                break
    elif is_fastapi or is_flask:
        info["type"] = "python-api"
        info["port"] = 8000
        info["dev_command"] = "uvicorn app:app --reload" if is_fastapi else "flask run"
        info["description"] = "Python API server"
    elif has_index_html:
        info["type"] = "static-site"
        info["entry_file"] = "index.html"
        info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
        info["description"] = "Static site -- serves HTML directly"
    elif has_package_json and "test" in pkg_scripts:
        info["type"] = "library"
        info["dev_command"] = "npm test"
        info["description"] = "Library/package -- run tests to verify"
    elif has_go_mod:
        info["type"] = "go-app"
        info["dev_command"] = "go run ."
        info["description"] = "Go application"
    elif has_cargo:
        info["type"] = "rust-app"
        info["dev_command"] = "cargo run"
        info["description"] = "Rust application"
    elif has_pom or has_gradle:
        info["type"] = "spring"
        info["port"] = 8080
        if has_pom:
            info["dev_command"] = "./mvnw spring-boot:run" if (project_root / "mvnw").exists() else "mvn spring-boot:run"
        else:
            info["dev_command"] = "./gradlew bootRun" if (project_root / "gradlew").exists() else "gradle bootRun"
        info["description"] = "Java Spring Boot application"
    elif has_gemfile and has_rails_routes:
        info["type"] = "rails"
        info["port"] = 3000
        info["dev_command"] = "bundle exec rails server"
        info["description"] = "Ruby on Rails application"
    elif has_artisan:
        info["type"] = "laravel"
        info["port"] = 8000
        info["dev_command"] = "php artisan serve"
        info["description"] = "PHP Laravel application"
    elif has_mix and (project_root / "lib").is_dir():
        info["type"] = "phoenix"
        info["port"] = 4000
        info["dev_command"] = "mix phx.server"
        info["description"] = "Elixir Phoenix application"
    elif has_package_swift:
        info["type"] = "swift"
        info["port"] = 8080
        info["dev_command"] = "swift run"
        info["description"] = "Swift/Vapor application"
    elif has_dockerfile:
        info["type"] = "containerized"
        info["dev_command"] = "docker compose up"
        info["description"] = "Containerized application"
    elif has_index_html and not has_package_json:
        info["type"] = "static"
        info["port"] = 8000
        info["entry_file"] = "index.html"
        info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
        info["dev_command"] = "python3 -m http.server 8000"
        info["description"] = "Static HTML/CSS/JS site"
    else:
        # Check for any README or docs
        found_doc = False
        for doc_file in ["README.md", "readme.md", "README.txt"]:
            if (target / doc_file).exists():
                info["type"] = "project"
                info["entry_file"] = doc_file
                info["preview_url"] = f"/api/sessions/{session_id}/preview/{doc_file}"
                info["description"] = "Project -- showing README"
                found_doc = True
                break
        if not found_doc:
            info["description"] = "Project type not detected -- use the custom command input to start a dev server"

    # Verify the entry file actually exists on disk before returning a preview URL
    if info["entry_file"]:
        entry_path = target / info["entry_file"]
        if not entry_path.exists():
            info["preview_url"] = None
            info["entry_file"] = None

    # Indicate whether AI-driven continuous log monitoring is active for this session
    server_info = dev_server_manager.servers.get(session_id)
    info["ai_detected"] = server_info is not None

    return JSONResponse(content=info)


@app.get("/api/sessions/{session_id}/expo-qr")
async def expo_qr_page(session_id: str) -> Response:
    """Return an HTML page with QR code for Expo Go."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})

    import socket
    # Get LAN IP for physical device access
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        lan_ip = s.getsockname()[0]
        s.close()
    except Exception:
        lan_ip = "localhost"

    server = dev_server_manager.servers.get(session_id)
    port = server["port"] if server and server.get("port") else 8081

    expo_url = f"exp://{lan_ip}:{port}"
    qr_api = f"https://api.qrserver.com/v1/create-qr-code/?size=250x250&data={expo_url}"

    html = f"""<!DOCTYPE html>
<html>
<head><style>
  body {{ font-family: system-ui, sans-serif; display: flex; flex-direction: column;
         align-items: center; justify-content: center; min-height: 100vh; margin: 0;
         background: #1a1a2e; color: #e0e0e0; }}
  .card {{ background: #16213e; border-radius: 16px; padding: 32px; text-align: center;
           box-shadow: 0 8px 32px rgba(0,0,0,0.3); max-width: 400px; }}
  h2 {{ margin: 0 0 8px; color: #fff; font-size: 20px; }}
  p {{ margin: 4px 0; color: #9ca3af; font-size: 14px; }}
  img {{ margin: 16px 0; border-radius: 8px; }}
  .url {{ font-family: monospace; background: #0f3460; padding: 8px 16px; border-radius: 8px;
          font-size: 13px; margin-top: 12px; color: #7c83ff; word-break: break-all; }}
  .instructions {{ margin-top: 16px; text-align: left; font-size: 13px; line-height: 1.6; }}
  .instructions li {{ margin: 4px 0; }}
  .web-link {{ margin-top: 16px; }}
  .web-link a {{ color: #7c83ff; text-decoration: none; font-size: 13px; }}
  .web-link a:hover {{ text-decoration: underline; }}
</style></head>
<body>
  <div class="card">
    <h2>Expo Go</h2>
    <p>Scan with Expo Go on your phone</p>
    <img src="{qr_api}" alt="QR Code" width="200" height="200" />
    <div class="url">{expo_url}</div>
    <div class="instructions">
      <ol>
        <li>Install <b>Expo Go</b> on your phone</li>
        <li>Make sure phone is on the same WiFi network</li>
        <li>Scan the QR code above</li>
      </ol>
    </div>
    <div class="web-link">
      <a href="http://{lan_ip}:{port}" target="_blank">Open web version</a>
    </div>
  </div>
</body>
</html>"""
    from starlette.responses import HTMLResponse
    return HTMLResponse(content=html)


# ---------------------------------------------------------------------------
# Dev server management endpoints
# ---------------------------------------------------------------------------


@app.post("/api/sessions/{session_id}/devserver/start")
async def start_devserver(session_id: str, req: DevServerStartRequest) -> JSONResponse:
    """Start a dev server for a session's project."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    try:
        result = await dev_server_manager.start(session_id, str(target), req.command)
    except Exception as e:
        logger.error("Dev server start failed: %s", e)
        result = {"status": "error", "message": str(e)}
    status_code = 200 if result.get("status") != "error" else 400
    return JSONResponse(content=result, status_code=status_code)


@app.post("/api/sessions/{session_id}/devserver/stop")
async def stop_devserver(session_id: str) -> JSONResponse:
    """Stop the dev server for a session."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    result = await dev_server_manager.stop(session_id)
    return JSONResponse(content=result)


@app.get("/api/sessions/{session_id}/devserver/status")
async def get_devserver_status(session_id: str) -> JSONResponse:
    """Get dev server status for a session."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    result = await dev_server_manager.status(session_id)
    return JSONResponse(content=result)


@app.get("/api/sessions/{session_id}/services")
async def get_session_services(session_id: str) -> JSONResponse:
    """Get Docker Compose service list with primary detection."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # Check if dev server info has cached services
    ds_info = dev_server_manager.servers.get(session_id)
    if ds_info and ds_info.get("docker_service_health"):
        return JSONResponse(content={
            "services": list(ds_info["docker_service_health"].values()),
            "framework": ds_info.get("framework"),
        })

    # Parse compose file directly
    services_info: list[dict] = []
    for compose_file in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
        if (target / compose_file).exists():
            try:
                import yaml
                with open(target / compose_file) as f:
                    compose_data = yaml.safe_load(f)
                if compose_data and "services" in compose_data:
                    for svc_name, svc in compose_data["services"].items():
                        svc_ports: list[int] = []
                        for p in svc.get("ports", []):
                            p_str = str(p)
                            if ":" in p_str:
                                parts = p_str.split(":")
                                try:
                                    svc_ports.append(int(parts[-2].split("-")[0]))
                                except (ValueError, IndexError):
                                    continue
                        services_info.append({
                            "name": svc_name,
                            "ports": svc_ports,
                            "image": svc.get("image"),
                            "has_build": "build" in svc,
                        })
            except (ImportError, Exception):
                pass
            break

    primary_name, primary_port = dev_server_manager._resolve_primary_service(services_info)
    for svc in services_info:
        svc["is_primary"] = (svc["name"] == primary_name)

    return JSONResponse(content={
        "services": services_info,
        "primary_service": primary_name,
        "primary_port": primary_port,
    })


@app.get("/api/sessions/{session_id}/devserver/logs")
async def get_devserver_logs(session_id: str, service: Optional[str] = None, tail: int = 50) -> JSONResponse:
    """Get Docker service logs (optionally filtered to one service)."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    ds_info = dev_server_manager.servers.get(session_id)
    if not ds_info:
        return JSONResponse(status_code=404, content={"error": "No dev server running"})

    # If not a Docker project, return buffered output lines
    if ds_info.get("framework") != "docker":
        return JSONResponse(content={
            "logs": ds_info.get("output_lines", [])[-tail:],
            "service": None,
        })

    # Docker project: use docker compose logs
    project_dir = ds_info.get("project_dir", str(target))
    tail = min(tail, 200)  # Cap at 200 lines
    try:
        cmd = ["docker", "compose", "logs", "--tail", str(tail)]
        if service:
            # Validate service name to prevent injection
            if not re.match(r"^[a-zA-Z0-9._-]+$", service):
                return JSONResponse(status_code=400, content={"error": "Invalid service name"})
            cmd.append(service)
        loop = asyncio.get_running_loop()
        log_proc = await loop.run_in_executor(None, lambda: subprocess.run(
            cmd, capture_output=True, text=True, cwd=project_dir, timeout=15
        ))
        logs_text = log_proc.stdout or ""
        # Run diagnosis on the logs
        diagnoses = _diagnose_errors(logs_text)
        return JSONResponse(content={
            "logs": logs_text[-5000:],
            "service": service,
            "diagnoses": diagnoses,
        })
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get logs: {exc}"})


@app.post("/api/sessions/{session_id}/devserver/restart-service")
async def restart_service(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Restart a specific Docker Compose service."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    ds_info = dev_server_manager.servers.get(session_id)
    if not ds_info:
        return JSONResponse(status_code=404, content={"error": "No dev server running"})
    if ds_info.get("framework") != "docker":
        return JSONResponse(status_code=400, content={"error": "Not a Docker project"})

    service_name = req.get("service")
    if not service_name or not re.match(r"^[a-zA-Z0-9._-]+$", service_name):
        return JSONResponse(status_code=400, content={"error": "Invalid or missing service name"})

    project_dir = ds_info.get("project_dir", ".")
    try:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["docker", "compose", "restart", service_name],
            capture_output=True, text=True, cwd=project_dir, timeout=30
        ))
        if result.returncode == 0:
            return JSONResponse(content={"status": "restarted", "service": service_name})
        else:
            return JSONResponse(status_code=500, content={
                "error": f"Restart failed: {result.stderr or result.stdout}",
                "service": service_name,
            })
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Restart failed: {exc}"})


# ---------------------------------------------------------------------------
# Deploy endpoints
# ---------------------------------------------------------------------------


# The deploy endpoint runs the receipt-gated CLI (`loki deploy --execute`)
# and reports only what that run recorded. It never invents a URL: a deploy
# whose output carried no URL returns url null.
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_DEPLOY_PLATFORM_PROVIDERS = {"vercel": "Vercel", "netlify": "Netlify"}
_DEPLOY_TIMEOUT_S = 600


def _strip_ansi(text: str) -> str:
    return _ANSI_RE.sub("", text or "")


def _deploy_detected_provider(advisory_output: str) -> Optional[str]:
    """First provider the print-only `loki deploy` advisory lists (the one --execute runs)."""
    seen_header = False
    for line in _strip_ansi(advisory_output).splitlines():
        if "Deploy options" in line:
            seen_header = True
            continue
        if seen_header:
            m = re.match(r"^  (\S[^:]*):\s*$", line)
            if m:
                return m.group(1)
    return None


def _cli_refused_reasons(output: str, fallback: str) -> list:
    """Every reason line a gated loki command printed for a refusal, never an empty list."""
    lines = _strip_ansi(output).splitlines()
    reasons = [l.strip()[2:].strip() for l in lines if l.startswith("  - ")]
    if not reasons:
        reasons = [l.strip() for l in lines if "REFUSED" in l or "Refused" in l]
    if not reasons:
        tail = [l.strip() for l in lines if l.strip()]
        reasons = [tail[-1] if tail else fallback]
    return reasons


@app.post("/api/sessions/{session_id}/deploy")
async def deploy_session(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Deploy through the receipt-gated `loki deploy --execute` and return its record."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    loki = _find_loki_cli()
    if loki is None:
        return JSONResponse(status_code=500, content={"error": "loki CLI not found", "url": None})

    project_dir = str(target)
    deploys_dir = Path(project_dir) / ".loki" / "deploys"
    env = {**os.environ, "LOKI_DIR": str(Path(project_dir) / ".loki")}
    loop = asyncio.get_running_loop()
    platform = str(req.get("platform") or "")

    try:
        if platform:
            # The platform is a hint only. It must name the provider the CLI
            # itself would run; the print-only advisory runs no cloud CLI.
            expected = _DEPLOY_PLATFORM_PROVIDERS.get(platform)
            if expected is None:
                reason = f"loki deploy has no provider for platform: {platform}"
                return JSONResponse(status_code=409, content={
                    "error": reason, "url": None, "refused_reasons": [reason],
                })
            advisory = await loop.run_in_executor(None, lambda: subprocess.run(
                [loki, "deploy", "--dir", project_dir, "--no-clip"],
                capture_output=True, text=True, cwd=project_dir, timeout=60, env=env,
            ))
            detected = _deploy_detected_provider((advisory.stdout or "") + (advisory.stderr or ""))
            if detected != expected:
                reason = (
                    f"platform {platform} does not match the provider loki deploy detected: "
                    f"{detected or 'none'}"
                )
                return JSONResponse(status_code=409, content={
                    "error": reason, "url": None, "refused_reasons": [reason],
                    "detected_provider": detected,
                })

        before = {p.name for p in deploys_dir.glob("*.json")} if deploys_dir.is_dir() else set()
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            [loki, "deploy", "--execute", "--dir", project_dir],
            capture_output=True, text=True, cwd=project_dir,
            timeout=_DEPLOY_TIMEOUT_S, env=env,
        ))
        output = _strip_ansi((result.stdout or "") + (result.stderr or ""))
        new_records = sorted(
            p for p in (deploys_dir.glob("*.json") if deploys_dir.is_dir() else [])
            if p.name not in before
        )
        if not new_records:
            if result.returncode != 0:
                # No record means the gate refused: nothing was executed.
                reasons = _cli_refused_reasons(output, "loki deploy exited non-zero with no output")
                return JSONResponse(status_code=422, content={
                    "error": "Deploy refused: " + "; ".join(reasons),
                    "url": None, "health_status": None, "live_binding": None,
                    "record_path": None, "refused_reasons": reasons, "output": output,
                })
            return JSONResponse(status_code=500, content={
                "error": "loki deploy exited 0 but wrote no deploy record",
                "url": None, "health_status": None, "live_binding": None,
                "record_path": None, "refused_reasons": [], "output": output,
            })

        record_path = new_records[-1]
        try:
            record = json.loads(record_path.read_text())
        except (OSError, ValueError) as exc:
            return JSONResponse(status_code=500, content={
                "error": f"Deploy record unreadable: {exc}", "url": None,
                "record_path": str(record_path), "refused_reasons": [], "output": output,
            })
        url = str(record.get("deployed_url") or "").strip() or None
        body = {
            "url": url,
            "health_status": record.get("health_status"),
            "live_binding": {
                "target": record.get("target"),
                "destination": record.get("destination"),
                "anchor_sha": record.get("anchor_sha"),
                "anchor_state": record.get("anchor_state"),
                "receipt_sha256": record.get("receipt_sha256"),
                "health_url": record.get("health_url"),
            },
            "record_path": str(record_path),
            "refused_reasons": [],
            "output": output,
        }
        if result.returncode != 0 or record.get("exit_code") not in (0, None):
            body["error"] = f"Deploy failed (loki deploy exited {result.returncode})"
            return JSONResponse(status_code=502, content=body)
        if url is None:
            body["message"] = "Deploy ran but its output carried no URL; none is reported."
        return JSONResponse(content=body)

    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={
            "error": f"Deploy timed out after {_DEPLOY_TIMEOUT_S} seconds", "url": None,
        })
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"loki CLI failed to run: {exc}", "url": None})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Deploy failed: {exc}", "url": None})


@app.post("/api/sessions/{session_id}/github/push")
async def github_push_session(session_id: str) -> JSONResponse:
    """Publish the session through the receipt-stamped `loki github publish` (private)."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})
    loki = _find_loki_cli()
    if loki is None:
        return JSONResponse(status_code=500, content={"error": "loki CLI not found", "repo_url": None})

    project_dir = str(target)
    env = {**os.environ, "LOKI_DIR": str(Path(project_dir) / ".loki")}
    loop = asyncio.get_running_loop()

    try:
        # Never stages files and never passes --public: the CLI refuses a dirty
        # tree, publishes private by default and stamps the loki/receipt status.
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            [loki, "github", "publish", "--dir", project_dir],
            capture_output=True, text=True, cwd=project_dir, timeout=120, env=env,
        ))
        output = _strip_ansi((result.stdout or "") + (result.stderr or ""))
        if result.returncode != 0:
            reasons = _cli_refused_reasons(output, "loki github publish exited non-zero with no output")
            refused = any("Refused" in l or "REFUSED" in l for l in output.splitlines())
            return JSONResponse(status_code=422 if refused else 502, content={
                "error": ("GitHub publish refused: " if refused else "GitHub publish failed: ")
                + "; ".join(reasons),
                "repo_url": None, "refused_reasons": reasons if refused else [],
                "output": output,
            })
        repo_url = None
        receipt_status = None
        for line in output.splitlines():
            m = re.match(r"^Repository: (\S+)\s*$", line)
            if m:
                repo_url = m.group(1)
            m = re.match(r"^loki/receipt status: (\S+)", line)
            if m:
                receipt_status = m.group(1)
        return JSONResponse(content={
            "repo_url": repo_url, "receipt_status": receipt_status,
            "refused_reasons": [], "output": output,
        })

    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "GitHub publish timed out", "repo_url": None})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"loki CLI failed to run: {exc}", "repo_url": None})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"GitHub publish failed: {exc}", "repo_url": None})


# ---------------------------------------------------------------------------
# Git integration endpoints
# ---------------------------------------------------------------------------


def _validate_session_and_find_dir(session_id: str) -> tuple[Optional[Path], Optional[JSONResponse]]:
    """Validate session ID and find directory. Returns (dir, None) or (None, error_response)."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return None, JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return None, JSONResponse(status_code=404, content={"error": "Session not found"})
    return target, None


def _run_git(cwd: Path, *args: str, timeout: int = 15) -> subprocess.CompletedProcess:
    """Run a git command in the given directory."""
    return subprocess.run(
        ["git"] + list(args),
        capture_output=True, text=True, cwd=str(cwd), timeout=timeout,
    )


@app.get("/api/sessions/{session_id}/git/status")
async def git_status(session_id: str) -> JSONResponse:
    """Get git status for a session's project."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    loop = asyncio.get_running_loop()
    try:
        # Get branch name
        branch_result = await loop.run_in_executor(
            None, lambda: _run_git(target, "rev-parse", "--abbrev-ref", "HEAD")
        )
        branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"

        # Get ahead/behind counts
        ahead, behind = 0, 0
        try:
            ab_result = await loop.run_in_executor(
                None, lambda: _run_git(target, "rev-list", "--left-right", "--count", f"{branch}...origin/{branch}")
            )
            if ab_result.returncode == 0:
                parts = ab_result.stdout.strip().split("\t")
                if len(parts) == 2:
                    ahead, behind = int(parts[0]), int(parts[1])
        except (ValueError, subprocess.TimeoutExpired):
            pass

        # Get porcelain status
        status_result = await loop.run_in_executor(
            None, lambda: _run_git(target, "status", "--porcelain=v1")
        )
        files = []
        if status_result.returncode == 0:
            for line in status_result.stdout.splitlines():
                if len(line) < 4:
                    continue
                index_status = line[0]
                worktree_status = line[1]
                filepath = line[3:].strip()
                # Remove quotes from paths with special characters
                if filepath.startswith('"') and filepath.endswith('"'):
                    filepath = filepath[1:-1]

                # Determine status and staged
                if index_status == '?' and worktree_status == '?':
                    files.append({"path": filepath, "status": "untracked", "staged": False})
                else:
                    # Index status (staged)
                    if index_status in ('M', 'A', 'D', 'R'):
                        status_map = {'M': 'modified', 'A': 'added', 'D': 'deleted', 'R': 'renamed'}
                        files.append({"path": filepath, "status": status_map.get(index_status, 'modified'), "staged": True})
                    # Worktree status (unstaged)
                    if worktree_status in ('M', 'D'):
                        status_map = {'M': 'modified', 'D': 'deleted'}
                        files.append({"path": filepath, "status": status_map.get(worktree_status, 'modified'), "staged": False})

        return JSONResponse(content={
            "branch": branch,
            "clean": len(files) == 0,
            "ahead": ahead,
            "behind": behind,
            "files": files,
        })
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Git status failed: {exc}"})


@app.get("/api/sessions/{session_id}/git/log")
async def git_log(session_id: str, limit: int = 20) -> JSONResponse:
    """Get commit history for a session's project."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    limit = min(limit, 100)  # Cap at 100
    loop = asyncio.get_running_loop()
    try:
        # Use a format that's easy to parse: hash|short_hash|author|date|refs|message
        fmt = "%H|%h|%an|%ar|%D|%s"
        result = await loop.run_in_executor(
            None, lambda: _run_git(target, "log", f"--format={fmt}", f"-{limit}")
        )
        if result.returncode != 0:
            return JSONResponse(content=[])

        commits = []
        for line in result.stdout.strip().splitlines():
            if not line:
                continue
            parts = line.split("|", 5)
            if len(parts) < 6:
                continue
            refs = [r.strip() for r in parts[4].split(",") if r.strip()] if parts[4] else []
            # Clean up ref names (remove HEAD -> prefix, etc.)
            clean_refs = []
            for ref in refs:
                ref = ref.replace("HEAD -> ", "").strip()
                if ref and ref != "HEAD":
                    clean_refs.append(ref)
            commits.append({
                "hash": parts[0],
                "short_hash": parts[1],
                "author": parts[2],
                "date": parts[3],
                "refs": clean_refs,
                "message": parts[5],
            })
        return JSONResponse(content=commits)
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Git log failed: {exc}"})


@app.get("/api/sessions/{session_id}/git/branches")
async def git_branches(session_id: str) -> JSONResponse:
    """List git branches for a session's project."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_git(target, "branch", "-a", "--format=%(refname:short)|%(HEAD)")
        )
        if result.returncode != 0:
            return JSONResponse(content=[])

        branches = []
        for line in result.stdout.strip().splitlines():
            if not line:
                continue
            parts = line.split("|", 1)
            name = parts[0].strip()
            is_current = len(parts) > 1 and parts[1].strip() == "*"
            is_remote = name.startswith("origin/") or name.startswith("remotes/")
            # Skip HEAD pointers
            if "HEAD" in name:
                continue
            branches.append({
                "name": name,
                "current": is_current,
                "remote": is_remote,
            })
        return JSONResponse(content=branches)
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Git branches failed: {exc}"})


@app.post("/api/sessions/{session_id}/git/commit")
async def git_commit(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Create a git commit in the session's project."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    message = req.get("message", "").strip()
    if not message:
        return JSONResponse(status_code=400, content={"error": "Commit message is required"})

    files = req.get("files")  # Optional: specific files to stage

    loop = asyncio.get_running_loop()
    try:
        # Stage files
        if files and isinstance(files, list):
            for f in files:
                if not isinstance(f, str) or ".." in f:
                    continue
                await loop.run_in_executor(None, lambda f=f: _run_git(target, "add", f))
        else:
            # Stage all changes
            await loop.run_in_executor(None, lambda: _run_git(target, "add", "-A"))

        # Commit
        result = await loop.run_in_executor(
            None, lambda: _run_git(target, "commit", "-m", message)
        )
        if result.returncode != 0:
            error_msg = result.stderr.strip() or result.stdout.strip() or "Commit failed"
            return JSONResponse(status_code=400, content={"error": error_msg})

        # Get the commit hash
        hash_result = await loop.run_in_executor(
            None, lambda: _run_git(target, "rev-parse", "--short", "HEAD")
        )
        commit_hash = hash_result.stdout.strip() if hash_result.returncode == 0 else "unknown"

        return JSONResponse(content={"hash": commit_hash, "message": message})
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Commit failed: {exc}"})


@app.post("/api/sessions/{session_id}/git/branch")
async def git_create_branch(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Create a new git branch."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    name = req.get("name", "").strip()
    checkout = req.get("checkout", True)
    if not name or not re.match(r"^[a-zA-Z0-9._/-]+$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid branch name"})

    loop = asyncio.get_running_loop()
    try:
        if checkout:
            result = await loop.run_in_executor(
                None, lambda: _run_git(target, "checkout", "-b", name)
            )
        else:
            result = await loop.run_in_executor(
                None, lambda: _run_git(target, "branch", name)
            )
        if result.returncode != 0:
            return JSONResponse(status_code=400, content={"error": result.stderr.strip() or "Branch creation failed"})

        return JSONResponse(content={"branch": name, "created": True})
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Branch creation failed: {exc}"})


@app.post("/api/sessions/{session_id}/git/checkout")
async def git_checkout_branch(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Switch to an existing git branch."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    name = req.get("name", "").strip()
    if not name or not re.match(r"^[a-zA-Z0-9._/-]+$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid branch name"})

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_git(target, "checkout", name)
        )
        if result.returncode != 0:
            return JSONResponse(status_code=400, content={"error": result.stderr.strip() or "Checkout failed"})

        return JSONResponse(content={"branch": name, "switched": True})
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Checkout failed: {exc}"})


@app.post("/api/sessions/{session_id}/git/push")
async def git_push(session_id: str) -> JSONResponse:
    """Push the current branch to remote."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    loop = asyncio.get_running_loop()
    try:
        # Get current branch
        branch_result = await loop.run_in_executor(
            None, lambda: _run_git(target, "rev-parse", "--abbrev-ref", "HEAD")
        )
        branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "main"

        # Push with upstream tracking
        result = await loop.run_in_executor(
            None, lambda: _run_git(target, "push", "-u", "origin", branch, timeout=30)
        )
        if result.returncode != 0:
            return JSONResponse(status_code=400, content={
                "error": result.stderr.strip() or "Push failed",
                "pushed": False,
            })

        return JSONResponse(content={"pushed": True, "message": f"Pushed {branch} to origin"})
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"Push failed: {exc}"})


@app.post("/api/sessions/{session_id}/git/pr")
async def git_create_pr(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Create a pull request using gh CLI."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    title = req.get("title", "").strip()
    body = req.get("body", "").strip()
    if not title:
        return JSONResponse(status_code=400, content={"error": "PR title is required"})

    loop = asyncio.get_running_loop()
    try:
        import shutil
        gh = shutil.which("gh")
        if not gh:
            return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})

        cmd = [gh, "pr", "create", "--title", title]
        if body:
            cmd.extend(["--body", body])
        else:
            cmd.extend(["--body", ""])

        result = await loop.run_in_executor(
            None, lambda: subprocess.run(
                cmd, capture_output=True, text=True, cwd=str(target), timeout=30,
            )
        )
        if result.returncode != 0:
            return JSONResponse(status_code=400, content={"error": result.stderr.strip() or "PR creation failed"})

        # gh pr create outputs the PR URL
        pr_url = result.stdout.strip()
        # Try to extract PR number from URL
        pr_number = 0
        if pr_url:
            parts = pr_url.rstrip("/").split("/")
            try:
                pr_number = int(parts[-1])
            except (ValueError, IndexError):
                pass

        return JSONResponse(content={"url": pr_url, "number": pr_number})
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
        return JSONResponse(status_code=500, content={"error": f"PR creation failed: {exc}"})


# ---------------------------------------------------------------------------
# Image upload for AI chat (screenshot-to-change)
# ---------------------------------------------------------------------------

_CHAT_IMAGE_MAX_BYTES = 10 * 1024 * 1024
_CHAT_IMAGE_READ_BYTES = 1024 * 1024
_CHAT_IMAGE_MULTIPART_OVERHEAD_BYTES = 64 * 1024
_CHAT_IMAGE_MAX_REQUEST_BYTES = _CHAT_IMAGE_MAX_BYTES + _CHAT_IMAGE_MULTIPART_OVERHEAD_BYTES
_CHAT_IMAGE_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})


class _ChatImageBodyTooLarge(Exception):
    pass


async def _parse_bounded_chat_image_form(request: Request):
    """Parse one multipart upload while bounding bytes and owning temp cleanup."""
    received = 0

    async def bounded_stream():
        nonlocal received
        async for chunk in request.stream():
            received += len(chunk)
            if received > _CHAT_IMAGE_MAX_REQUEST_BYTES:
                raise _ChatImageBodyTooLarge
            yield chunk

    parser = MultiPartParser(
        headers=request.headers,
        stream=bounded_stream(),
        max_files=1,
        max_fields=1000,
    )
    try:
        return await parser.parse()
    except BaseException:
        # Starlette closes these files for MultiPartException only. Transport
        # disconnects and receive errors must have the same deterministic
        # cleanup rather than relying on interpreter-specific destruction.
        for upload_temp in parser._files_to_close_on_error:
            upload_temp.close()
        raise


def _chat_image_matches_type(content: bytes, content_type: str) -> bool:
    if content_type == "image/png":
        if not content.startswith(b"\x89PNG\r\n\x1a\n"):
            return False
        offset = 8
        first_chunk = True
        while offset + 12 <= len(content):
            chunk_size = int.from_bytes(content[offset:offset + 4], "big")
            chunk_type = content[offset + 4:offset + 8]
            chunk_end = offset + 12 + chunk_size
            if chunk_end > len(content):
                return False
            if first_chunk and chunk_type != b"IHDR":
                return False
            first_chunk = False
            if chunk_type == b"IEND":
                return chunk_size == 0 and chunk_end == len(content)
            offset = chunk_end
        return False
    if content_type == "image/jpeg":
        return (
            len(content) >= 5
            and content.startswith(b"\xff\xd8\xff")
            and content.endswith(b"\xff\xd9")
        )
    if content_type == "image/gif":
        return (
            len(content) >= 14
            and content.startswith((b"GIF87a", b"GIF89a"))
            and content.endswith(b"\x3b")
        )
    if content_type == "image/webp":
        return (
            len(content) >= 20
            and content[:4] == b"RIFF"
            and content[8:12] == b"WEBP"
            and int.from_bytes(content[4:8], "little") == len(content) - 8
        )
    return False


async def _store_chat_image(target: Path, image_file) -> JSONResponse:
    content_type = (getattr(image_file, "content_type", "") or "").lower()
    if content_type not in _CHAT_IMAGE_TYPES:
        return JSONResponse(
            status_code=415,
            content={"error": "Unsupported image type. Use PNG, JPEG, GIF, or WebP"},
        )

    chunks = []
    total_bytes = 0
    while True:
        chunk = await image_file.read(_CHAT_IMAGE_READ_BYTES)
        if not chunk:
            break
        total_bytes += len(chunk)
        if total_bytes > _CHAT_IMAGE_MAX_BYTES:
            return JSONResponse(
                status_code=413,
                content={"error": "Image too large (max 10MB)"},
            )
        chunks.append(chunk)
    content = b"".join(chunks)
    if not _chat_image_matches_type(content, content_type):
        return JSONResponse(
            status_code=415,
            content={"error": "Image content is invalid, incomplete, or does not match its declared type"},
        )

    images_dir = target / ".loki" / "images"
    images_dir.mkdir(parents=True, exist_ok=True)

    image_id = str(uuid.uuid4())[:8]
    original_name = getattr(image_file, "filename", "image.png") or "image.png"
    safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", original_name)
    saved_name = f"{image_id}_{safe_name}"
    saved_path = images_dir / saved_name

    with open(saved_path, "wb") as output:
        output.write(content)

    return JSONResponse(content={
        "image_id": image_id,
        "filename": safe_name,
        "path": str(saved_path.relative_to(target)),
        "size": len(content),
    })


@app.post("/api/sessions/{session_id}/chat/image")
async def chat_image_upload(session_id: str, request: Request) -> JSONResponse:
    """Upload an image for screenshot-to-change in AI chat."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    raw_content_length = request.headers.get("content-length")
    if raw_content_length is not None:
        try:
            declared_bytes = int(raw_content_length, 10)
        except (TypeError, ValueError):
            return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
        if declared_bytes <= 0:
            return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
        if declared_bytes > _CHAT_IMAGE_MAX_REQUEST_BYTES:
            return JSONResponse(status_code=413, content={"error": "Image too large (max 10MB)"})

    form = None
    try:
        form = await _parse_bounded_chat_image_form(request)
        image_file = form.get("image")
        if image_file is None:
            return JSONResponse(status_code=400, content={"error": "No image file provided"})
        return await _store_chat_image(target, image_file)
    except _ChatImageBodyTooLarge:
        return JSONResponse(status_code=413, content={"error": "Image too large (max 10MB)"})
    except (ClientDisconnect, OSError):
        logger.info("Image upload transport ended before completion")
        return JSONResponse(status_code=400, content={"error": "Image upload was interrupted"})
    except MultiPartException:
        return JSONResponse(status_code=400, content={"error": "Invalid multipart image upload"})
    except Exception:
        logger.error("Image upload failed", exc_info=True)
        return JSONResponse(status_code=500, content={"error": "Image upload failed"})
    finally:
        if form is not None:
            try:
                await form.close()
            except Exception:
                logger.warning("Image upload temporary-file cleanup failed", exc_info=True)


# ---------------------------------------------------------------------------
# HTTP Proxy for dev server preview
# ---------------------------------------------------------------------------


@app.api_route("/proxy/{session_id}/{path:path}",
               methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_to_devserver(session_id: str, path: str, request: Request):
    """Proxy requests to the dev server running for this session."""
    try:
        return await _do_proxy(session_id, path, request)
    except Exception as e:
        logger.error("Proxy error for %s/%s: %s", session_id, path, e, exc_info=True)
        return JSONResponse({"error": f"Proxy failed: {e}"}, status_code=502)


async def _do_proxy(session_id: str, path: str, request: Request):
    """Internal proxy implementation.

    Uses streaming to handle large responses without buffering them entirely
    in memory (important for JS bundles, images, etc.).
    """
    import httpx

    server = dev_server_manager.servers.get(session_id)
    if not server or server["status"] != "running" or server.get("port") is None:
        return JSONResponse(
            {"error": "Dev server not running", "hint": "Start the dev server first"},
            status_code=503,
        )

    # Determine target: portless URL or direct port
    if server.get("use_portless") and server.get("portless_app_name"):
        app_name = server["portless_app_name"]
        target_host = f"{app_name}.localhost"
        target_port = 1355
        target_url = f"http://{target_host}:{target_port}/{path}"
    else:
        target_port = server["port"]
        target_host = f"127.0.0.1:{target_port}"
        target_url = f"http://127.0.0.1:{target_port}/{path}"

    if request.url.query:
        target_url += f"?{request.url.query}"

    # Build headers to forward (skip hop-by-hop headers)
    skip_headers = {"host", "connection", "keep-alive", "transfer-encoding",
                    "te", "trailer", "upgrade"}
    fwd_headers = {
        k: v for k, v in request.headers.items()
        if k.lower() not in skip_headers
    }
    fwd_headers["host"] = target_host

    body = await request.body()

    # Use a client that is NOT used as a context manager so we can stream
    # the response body without closing the connection prematurely.
    client = httpx.AsyncClient(timeout=60.0, follow_redirects=False)
    try:
        resp = await client.send(
            client.build_request(
                method=request.method,
                url=target_url,
                headers=fwd_headers,
                content=body if body else None,
            ),
            stream=True,
        )
    except httpx.ConnectError:
        await client.aclose()
        return JSONResponse(
            {"error": "Cannot connect to dev server", "port": target_port},
            status_code=502,
        )
    except httpx.TimeoutException:
        await client.aclose()
        return JSONResponse(
            {"error": "Dev server request timed out"},
            status_code=504,
        )
    except Exception as e:
        await client.aclose()
        return JSONResponse(
            {"error": f"Proxy error: {e}"},
            status_code=502,
        )

    # Build response headers, passing through relevant ones
    resp_skip = {"transfer-encoding", "connection", "keep-alive", "content-encoding"}
    resp_headers = {
        k: v for k, v in resp.headers.items()
        if k.lower() not in resp_skip
    }

    async def stream_body():
        try:
            async for chunk in resp.aiter_bytes(chunk_size=65536):
                yield chunk
        finally:
            await resp.aclose()
            await client.aclose()

    return StreamingResponse(
        content=stream_body(),
        status_code=resp.status_code,
        headers=resp_headers,
    )


@app.websocket("/proxy/{session_id}/{path:path}")
async def proxy_websocket(websocket: WebSocket, session_id: str, path: str):
    """Proxy WebSocket connections for HMR (Vite, webpack, etc.).

    Handles any WS path under /proxy/{session_id}/ so that Vite's
    /__vite_hmr, webpack's /ws, and other HMR paths all work.
    """
    import websockets

    server = dev_server_manager.servers.get(session_id)
    if not server or server["status"] != "running" or server.get("port") is None:
        await websocket.close(code=1008, reason="Dev server not running")
        return

    # Determine WebSocket target: portless or direct
    if server.get("use_portless") and server.get("portless_app_name"):
        app_name = server["portless_app_name"]
        ws_url = f"ws://{app_name}.localhost:1355/{path}"
    else:
        target_port = server["port"]
        ws_url = f"ws://127.0.0.1:{target_port}/{path}"

    await websocket.accept()

    try:
        async with websockets.connect(ws_url) as upstream:
            async def client_to_upstream():
                try:
                    while True:
                        msg = await websocket.receive()
                        if msg.get("text") is not None:
                            await upstream.send(msg["text"])
                        elif msg.get("bytes") is not None:
                            await upstream.send(msg["bytes"])
                except (WebSocketDisconnect, Exception):
                    pass

            async def upstream_to_client():
                try:
                    async for msg in upstream:
                        if isinstance(msg, str):
                            await websocket.send_text(msg)
                        elif isinstance(msg, bytes):
                            await websocket.send_bytes(msg)
                except Exception:
                    pass

            await asyncio.gather(
                client_to_upstream(),
                upstream_to_client(),
                return_exceptions=True,
            )
    except Exception:
        pass
    finally:
        try:
            await websocket.close()
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Authentication middleware
# ---------------------------------------------------------------------------


@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    """Enforce JWT auth when database is configured. Skip for public paths."""
    path = request.url.path
    skip_auth_prefixes = ["/health", "/api/auth/"]
    if any(path.startswith(p) for p in skip_auth_prefixes) or not (
        path.startswith("/api/") or path.startswith("/ws") or path.startswith("/proxy/")
    ):
        return await call_next(request)

    # If no DB configured, skip auth (local mode)
    try:
        from models import async_session_factory
        if async_session_factory is None:
            return await call_next(request)
    except ImportError:
        return await call_next(request)

    # Verify JWT
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        return JSONResponse({"error": "Not authenticated"}, status_code=401)

    try:
        from auth import verify_token
    except ImportError:
        # Auth module not installed but database IS configured -- block the request
        logger.warning("Auth module not available but database is configured -- blocking request")
        return JSONResponse({"error": "Server authentication misconfigured"}, status_code=500)

    token = auth_header.split(" ", 1)[1]
    payload = verify_token(token)
    if not payload:
        return JSONResponse({"error": "Invalid or expired token"}, status_code=401)
    request.state.user = payload

    return await call_next(request)


# ---------------------------------------------------------------------------
# Auth endpoints
# ---------------------------------------------------------------------------


@app.get("/api/auth/me")
async def get_me(request: Request) -> JSONResponse:
    """Get current user info. Returns local_mode=True when no auth configured."""
    try:
        from models import async_session_factory
        if async_session_factory is None:
            return JSONResponse(content={"authenticated": False, "local_mode": True})
    except ImportError:
        return JSONResponse(content={"authenticated": False, "local_mode": True})

    user = getattr(request.state, "user", None)
    if user is None:
        return JSONResponse(content={"authenticated": False, "local_mode": False})
    return JSONResponse(content={"authenticated": True, **user})


@app.get("/api/auth/github/url")
async def github_auth_url() -> JSONResponse:
    """Get GitHub OAuth authorization URL with CSRF state parameter."""
    try:
        from auth import GITHUB_CLIENT_ID, generate_oauth_state
    except ImportError:
        return JSONResponse(status_code=501, content={"error": "Auth module not available"})
    if not GITHUB_CLIENT_ID:
        return JSONResponse(status_code=501, content={"error": "GitHub OAuth not configured"})
    state = generate_oauth_state()
    url = f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=user:email&state={state}"
    return JSONResponse(content={"url": url})


@app.post("/api/auth/github/callback")
async def github_callback(body: dict = Body(...)) -> JSONResponse:
    """Handle GitHub OAuth callback -- exchange code for JWT."""
    try:
        from auth import create_access_token, github_oauth_callback, validate_oauth_state
        from models import async_session_factory, User
    except ImportError:
        return JSONResponse(status_code=501, content={"error": "Auth module not available"})

    code = body.get("code")
    state = body.get("state")
    if not code:
        return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
    if not validate_oauth_state(state):
        return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})

    try:
        user_info = await github_oauth_callback(code)
    except HTTPException:
        raise
    except Exception as exc:
        logger.error("GitHub OAuth callback failed: %s", exc)
        return JSONResponse(status_code=502, content={"error": "GitHub authentication failed"})

    # Create or update user in DB if database is configured
    if async_session_factory:
        from sqlalchemy import select
        async with async_session_factory() as db:
            result = await db.execute(
                select(User).where(User.email == user_info["email"])
            )
            db_user = result.scalar_one_or_none()
            if db_user is None:
                db_user = User(
                    email=user_info["email"],
                    name=user_info["name"],
                    avatar_url=user_info.get("avatar_url"),
                    provider="github",
                    provider_id=user_info["provider_id"],
                )
                db.add(db_user)
            else:
                db_user.name = user_info["name"]
                db_user.avatar_url = user_info.get("avatar_url")
                db_user.last_login = datetime.utcnow()
            await db.commit()

    token = create_access_token({
        "sub": user_info["email"],
        "name": user_info["name"],
        "avatar": user_info.get("avatar_url", ""),
    })
    return JSONResponse(content={"token": token, "user": user_info})


@app.get("/api/auth/google/url")
async def google_auth_url() -> JSONResponse:
    """Get Google OAuth authorization URL with CSRF state parameter."""
    try:
        from auth import GOOGLE_CLIENT_ID, generate_oauth_state
    except ImportError:
        return JSONResponse(status_code=501, content={"error": "Auth module not available"})
    if not GOOGLE_CLIENT_ID:
        return JSONResponse(status_code=501, content={"error": "Google OAuth not configured"})
    redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")
    state = generate_oauth_state()
    url = (
        f"https://accounts.google.com/o/oauth2/v2/auth"
        f"?client_id={GOOGLE_CLIENT_ID}"
        f"&redirect_uri={redirect_uri}"
        f"&response_type=code"
        f"&scope=openid%20email%20profile"
        f"&state={state}"
    )
    return JSONResponse(content={"url": url})


@app.post("/api/auth/google/callback")
async def google_callback(body: dict = Body(...)) -> JSONResponse:
    """Handle Google OAuth callback -- exchange code for JWT."""
    try:
        from auth import create_access_token, google_oauth_callback, validate_oauth_state
        from models import async_session_factory, User
    except ImportError:
        return JSONResponse(status_code=501, content={"error": "Auth module not available"})

    code = body.get("code")
    state = body.get("state")
    if not code:
        return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
    if not validate_oauth_state(state):
        return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})

    # Use server-controlled redirect_uri -- never trust client-supplied value
    redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")

    try:
        user_info = await google_oauth_callback(code, redirect_uri)
    except HTTPException:
        raise
    except Exception as exc:
        logger.error("Google OAuth callback failed: %s", exc)
        return JSONResponse(status_code=502, content={"error": "Google authentication failed"})

    # Create or update user in DB if database is configured
    if async_session_factory:
        from sqlalchemy import select
        async with async_session_factory() as db:
            result = await db.execute(
                select(User).where(User.email == user_info["email"])
            )
            db_user = result.scalar_one_or_none()
            if db_user is None:
                db_user = User(
                    email=user_info["email"],
                    name=user_info["name"],
                    avatar_url=user_info.get("avatar_url"),
                    provider="google",
                    provider_id=user_info["provider_id"],
                )
                db.add(db_user)
            else:
                db_user.name = user_info["name"]
                db_user.avatar_url = user_info.get("avatar_url")
                db_user.last_login = datetime.utcnow()
            await db.commit()

    token = create_access_token({
        "sub": user_info["email"],
        "name": user_info["name"],
        "avatar": user_info.get("avatar_url", ""),
    })
    return JSONResponse(content={"token": token, "user": user_info})


# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------


@app.get("/health")
async def health_check() -> JSONResponse:
    """Health check for load balancers and orchestrators."""
    return JSONResponse(content={"status": "ok", "service": "purple-lab"})


# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------


async def _push_state_to_client(ws: WebSocket) -> None:
    """Background task: push state snapshots to a single WebSocket client.

    Pushes every 2s when a session is running, every 30s when idle.
    Sends only incremental log deltas (new lines since last push) instead
    of the full log buffer each time.
    """
    # Track absolute log offset to handle truncation correctly.
    # The buffer holds only the last N lines, but log_lines_total counts all.
    last_abs_index = max(session.log_lines_total - 100, 0)  # backfill handled on connect
    while True:
        is_running = (
            session.process is not None
            and session.running
            and session.process.poll() is None
        )
        interval = 2.0 if is_running else 30.0

        # Build status payload (same logic as GET /api/session/status)
        # Use asyncio.to_thread to avoid blocking the event loop on file I/O
        def _read_state_files():
            loki_dir = _loki_dir()
            # Same unknown-not-zero defaults as GET /api/session/status.
            _phase = "idle"
            _iteration = None
            _complexity = None
            _current_task = ""
            _pending_tasks = None
            _agents = []
            _cost_usd = None
            _max_iterations = 0

            # BUG-INT-002 fix: CLI writes dashboard-state.json, not state/session.json
            dash_file = loki_dir / "dashboard-state.json"
            if dash_file.exists():
                try:
                    with open(dash_file) as f:
                        state_data = json.load(f)
                    _phase = state_data.get("phase", _phase)
                    _iteration = state_data.get("iteration", _iteration)
                    _complexity = state_data.get("complexity", _complexity)
                    _tasks = state_data.get("tasks")
                    if isinstance(_tasks, dict):
                        _tp = _tasks.get("pending", 0)
                        _tip = _tasks.get("inProgress", 0)
                        _pending_tasks = len(_tp) if isinstance(_tp, list) else int(_tp or 0)
                        _in_progress = len(_tip) if isinstance(_tip, list) else int(_tip or 0)
                        if _in_progress > 0:
                            _current_task = f"{_in_progress} task(s) in progress"
                    _cost_usd = _measured_state_cost(state_data.get("tokens"))
                    # Agents are included in dashboard-state.json
                    _dash_agents = state_data.get("agents")
                    if isinstance(_dash_agents, list):
                        _agents = _dash_agents
                except (json.JSONDecodeError, OSError):
                    pass
            else:
                # Fallback: try orchestrator.json for phase
                _orch_file = loki_dir / "state" / "orchestrator.json"
                if _orch_file.exists():
                    try:
                        with open(_orch_file) as f:
                            _orch = json.load(f)
                        _phase = _orch.get("currentPhase", _phase)
                    except (json.JSONDecodeError, OSError):
                        pass

            agents_file = loki_dir / "state" / "agents.json"
            if agents_file.exists():
                try:
                    with open(agents_file) as f:
                        agents_data = json.load(f)
                    if isinstance(agents_data, list):
                        _agents = agents_data
                except (json.JSONDecodeError, OSError):
                    pass

            # Read max_iterations from autonomy state
            autonomy_state = loki_dir / "autonomy-state.json"
            if autonomy_state.exists():
                try:
                    with open(autonomy_state) as f:
                        astate = json.load(f)
                    _max_iterations = int(astate.get("maxIterations", 0) or 0)
                except (json.JSONDecodeError, OSError, ValueError):
                    pass

            _max_iterations = _max_iterations_or_none(_max_iterations)

            return _phase, _iteration, _complexity, _current_task, _pending_tasks, _agents, _cost_usd, _max_iterations

        phase, iteration, complexity, current_task, pending_tasks, agents_payload, ws_cost, ws_max_iter = (
            await asyncio.to_thread(_read_state_files)
        )

        uptime = time.time() - session.start_time if is_running else 0
        status_payload = {
            "running": session.running,
            "paused": session.paused,
            "phase": phase,
            "iteration": iteration,
            "complexity": complexity,
            "mode": "autonomous",
            "provider": session.provider,
            "current_task": current_task,
            "pending_tasks": pending_tasks,
            "running_agents": None,
            "uptime": round(uptime),
            "version": "",
            "pid": str(session.process.pid) if session.process else "",
            "projectDir": session.project_dir,
            "max_iterations": ws_max_iter,
            "cost": round(ws_cost, 4) if ws_cost is not None else None,
            "start_time": session.start_time if session.start_time > 0 else 0,
        }

        # Build incremental logs payload using absolute offset to handle truncation
        total_now = session.log_lines_total
        buf_len = len(session.log_lines)
        buf_start = total_now - buf_len  # absolute index of first item in buffer
        if last_abs_index < buf_start:
            last_abs_index = buf_start  # skip lines that were truncated away
        relative_start = last_abs_index - buf_start
        new_lines = session.log_lines[relative_start:] if relative_start < buf_len else []
        last_abs_index = total_now
        logs_payload = []
        for line in new_lines:
            level = "info"
            lower = line.lower()
            if "error" in lower or "fail" in lower:
                level = "error"
            elif "warn" in lower:
                level = "warning"
            elif "debug" in lower:
                level = "debug"
            logs_payload.append({
                "timestamp": "",
                "level": level,
                "message": line,
                "source": "loki",
            })

        try:
            await ws.send_json({
                "type": "state_update",
                "data": {
                    "status": status_payload,
                    "agents": agents_payload,
                    "logs": logs_payload,
                },
            })
        except Exception:
            # Client disconnected; exit task
            return

        await asyncio.sleep(interval)


@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
    """Real-time stream of loki output and events."""
    await ws.accept()
    if len(session.ws_clients) >= MAX_WS_CLIENTS:
        await ws.send_text(json.dumps({"type": "error", "data": {"message": "Too many connections"}}))
        await ws.close(code=1013, reason="Too many connections")
        return
    session.ws_clients.add(ws)

    # Send current state on connect
    await ws.send_text(json.dumps({
        "type": "connected",
        "data": {"running": session.running, "provider": session.provider},
    }))

    # Ensure file watcher is running if session is active (handles page reloads)
    if session.running and session.project_dir and "session" not in file_watcher._observers:
        file_watcher.start("session", session.project_dir, _broadcast, asyncio.get_running_loop())

    # Send recent log lines as backfill (with sequence numbers for ordering)
    # BUG-E2E-002: Include seq so frontend can maintain correct order
    backfill_lines = session.log_lines[-100:]
    backfill_start_seq = max(1, session.log_lines_total - len(backfill_lines) + 1)
    for i, line in enumerate(backfill_lines):
        await ws.send_text(json.dumps({
            "type": "log",
            "data": {"line": line, "timestamp": "", "seq": backfill_start_seq + i},
        }))

    # Start server-push state task for this connection
    push_task = asyncio.create_task(_push_state_to_client(ws))

    missed_pongs = 0
    try:
        while True:
            try:
                data = await asyncio.wait_for(ws.receive_text(), timeout=60.0)
                try:
                    msg = json.loads(data)
                    if msg.get("type") == "ping":
                        await ws.send_text(json.dumps({"type": "pong"}))
                    elif msg.get("type") == "pong":
                        missed_pongs = 0  # only reset on pong-type messages
                except json.JSONDecodeError:
                    pass
            except asyncio.TimeoutError:
                # No message for 60s -- send a ping
                missed_pongs += 1
                if missed_pongs >= 2:
                    # Two consecutive pings with no reply -- close idle connection
                    break
                try:
                    await ws.send_text(json.dumps({"type": "ping"}))
                except Exception:
                    break
    except WebSocketDisconnect:
        pass
    finally:
        push_task.cancel()
        try:
            await push_task
        except (asyncio.CancelledError, Exception):
            pass
        session.ws_clients.discard(ws)


# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Terminal PTY WebSocket (interactive shell per session)
# ---------------------------------------------------------------------------

# Track active WebSocket connections per session for multi-tab awareness
_terminal_ws_clients: Dict[str, set] = {}

# Track active PTY reader tasks per session to prevent duplicate readers
_terminal_reader_tasks: Dict[str, asyncio.Task] = {}


@app.websocket("/ws/terminal/{session_id}")
async def terminal_websocket(ws: WebSocket, session_id: str) -> None:
    """Interactive terminal via PTY. Requires pexpect.

    Reuses an existing PTY if one is already alive for this session (e.g. on
    reconnect or second browser tab). Only kills the PTY when the *last*
    WebSocket client for this session disconnects.
    """
    await ws.accept()
    if len(_terminal_ptys) >= MAX_TERMINAL_PTYS and session_id not in _terminal_ptys:
        await ws.send_text(json.dumps({"type": "error", "data": {"message": "Too many terminal sessions"}}))
        await ws.close(code=1013, reason="Too many terminal sessions")
        return

    if not HAS_PEXPECT:
        # Try to install pexpect automatically
        import subprocess as _sp
        try:
            _sp.run(
                [sys.executable, "-m", "pip", "install", "--break-system-packages", "pexpect"],
                capture_output=True, timeout=30,
            )
            import pexpect as _pex  # noqa: F811
            globals()["pexpect"] = _pex
            globals()["HAS_PEXPECT"] = True
        except Exception:
            await ws.send_text(json.dumps({
                "type": "output",
                "data": "\r\n[Error] pexpect is not installed and auto-install failed.\r\n"
                        "Run: pip install pexpect\r\n",
            }))
            # Close with 4000 = "do not retry" custom code
            await ws.close(code=4000, reason="pexpect not installed")
            return

    # ---- Reuse or spawn PTY ------------------------------------------------
    pty = _terminal_ptys.get(session_id)
    spawned_new = False
    if pty is None or not pty.isalive():
        # Determine working directory
        cwd = str(Path.home())
        session_dir = _find_session_dir(session_id)
        if session_dir and session_dir.is_dir():
            cwd = str(session_dir)
        elif session.project_dir and Path(session.project_dir).is_dir():
            cwd = session.project_dir

        # Build environment with secrets injected
        env = os.environ.copy()
        env["TERM"] = "xterm-256color"
        try:
            secrets = _load_secrets()
            env.update(secrets)
        except Exception:
            pass

        # Prefer user configured shell, fall back to /bin/bash
        user_shell = os.environ.get("SHELL", "/bin/bash")
        if not os.path.isfile(user_shell):
            user_shell = "/bin/bash"

        try:
            pty = pexpect.spawn(
                user_shell,
                args=["--login"],
                encoding="utf-8",
                codec_errors="replace",
                cwd=cwd,
                env=env,
                dimensions=(24, 80),
            )
            pty.setecho(True)
            spawned_new = True
        except Exception as exc:
            await ws.send_text(json.dumps({
                "type": "output",
                "data": f"\r\n[Error] Failed to spawn terminal: {exc}\r\n",
            }))
            await ws.close()
            return

        _terminal_ptys[session_id] = pty

    # ---- Track this WebSocket client ----------------------------------------
    if session_id not in _terminal_ws_clients:
        _terminal_ws_clients[session_id] = set()
    ws_id = id(ws)
    _terminal_ws_clients[session_id].add(ws_id)

    if not spawned_new:
        try:
            await ws.send_text(json.dumps({
                "type": "output",
                "data": "\r\n\x1b[32m-- Reconnected to existing terminal session --\x1b[0m\r\n",
            }))
        except Exception:
            pass

    # ---- Background task: read PTY output and forward to WebSocket ----------
    # Only create one reader per PTY to avoid race conditions when multiple
    # tabs connect to the same terminal session.
    async def read_pty_output() -> None:
        loop = asyncio.get_event_loop()
        while True:
            try:
                data = await loop.run_in_executor(None, _pty_read, pty)
                if data is None:
                    # PTY closed / EOF
                    try:
                        await ws.send_text(json.dumps({
                            "type": "output",
                            "data": "\r\n[Terminal session ended]\r\n",
                        }))
                    except Exception:
                        pass
                    break
                if data:
                    await ws.send_text(json.dumps({
                        "type": "output",
                        "data": data,
                    }))
            except asyncio.CancelledError:
                break
            except Exception:
                break

    existing_reader = _terminal_reader_tasks.get(session_id)
    if existing_reader is not None and not existing_reader.done():
        # A reader already exists for this PTY -- reuse it, don't create another
        reader_task = None
    else:
        reader_task = asyncio.create_task(read_pty_output())
        _terminal_reader_tasks[session_id] = reader_task

    try:
        while True:
            raw = await ws.receive_text()
            try:
                msg = json.loads(raw)
            except json.JSONDecodeError:
                continue

            msg_type = msg.get("type", "")

            if msg_type == "input":
                data = msg.get("data", "")
                if data and pty.isalive():
                    pty.send(data)

            elif msg_type == "resize":
                cols = msg.get("cols", 80)
                rows = msg.get("rows", 24)
                if pty.isalive():
                    pty.setwinsize(rows, cols)

    except WebSocketDisconnect:
        pass
    except Exception:
        logger.error("Terminal WebSocket error for session %s", session_id, exc_info=True)
    finally:
        if reader_task is not None:
            reader_task.cancel()
            try:
                await reader_task
            except (asyncio.CancelledError, Exception):
                pass
            _terminal_reader_tasks.pop(session_id, None)

        # Untrack this client
        clients = _terminal_ws_clients.get(session_id)
        if clients:
            clients.discard(ws_id)

        # Only kill PTY when the last client disconnects
        remaining = _terminal_ws_clients.get(session_id)
        if not remaining:
            _terminal_ws_clients.pop(session_id, None)
            if pty.isalive():
                pty.close(force=True)
            _terminal_ptys.pop(session_id, None)


def _pty_read(pty: "pexpect.spawn") -> Optional[str]:
    """Blocking read from PTY with batching.

    Uses a longer timeout (0.5s) to avoid busy-looping when idle.
    When data IS available, greedily reads more to batch output
    (up to 32KB per batch to keep latency reasonable).
    Returns None on EOF.
    """
    try:
        data = pty.read_nonblocking(size=4096, timeout=0.5)
        # Greedily read more available data to batch output (e.g. cat large_file)
        total = len(data) if data else 0
        while total < 32768:
            try:
                more = pty.read_nonblocking(size=4096, timeout=0.01)
                if more:
                    data += more
                    total += len(more)
                else:
                    break
            except (pexpect.TIMEOUT, pexpect.EOF, Exception):
                break
        return data
    except pexpect.TIMEOUT:
        return ""
    except pexpect.EOF:
        return None
    except Exception:
        return None



# ---------------------------------------------------------------------------
# Checkpoint Timeline (Sprint 3.1)
# ---------------------------------------------------------------------------


def _get_checkpoints_dir(session_dir: Path) -> Path:
    """Return the checkpoints directory for a session."""
    return session_dir / ".loki" / "checkpoints"


def _list_checkpoints(session_dir: Path) -> list[dict]:
    """List all checkpoints for a session, sorted by timestamp."""
    cp_dir = _get_checkpoints_dir(session_dir)
    checkpoints: list[dict] = []

    if not cp_dir.is_dir():
        return checkpoints

    for entry in sorted(cp_dir.iterdir()):
        if not entry.is_dir():
            continue
        meta_file = entry / "meta.json"
        if meta_file.exists():
            try:
                meta = json.loads(meta_file.read_text())
                checkpoints.append({
                    "id": entry.name,
                    "timestamp": meta.get("timestamp", ""),
                    "description": meta.get("description", f"Checkpoint {entry.name}"),
                    # Unrecorded counts are None ("--"), never 0.
                    "iteration": meta.get("iteration"),
                    "files_changed": meta.get("files_changed"),
                    "is_current": False,
                })
            except (json.JSONDecodeError, OSError):
                checkpoints.append({
                    "id": entry.name,
                    "timestamp": "",
                    "description": f"Checkpoint {entry.name}",
                    "iteration": None,
                    "files_changed": None,
                    "is_current": False,
                })
        else:
            # Create entry from directory name
            checkpoints.append({
                "id": entry.name,
                "timestamp": time.strftime(
                    "%Y-%m-%dT%H:%M:%SZ", time.localtime(entry.stat().st_mtime)
                ),
                "description": f"Checkpoint {entry.name}",
                "iteration": None,
                "files_changed": None,
                "is_current": False,
            })

    # Mark the latest as current
    if checkpoints:
        checkpoints[-1]["is_current"] = True

    return checkpoints


@app.get("/api/sessions/{session_id}/checkpoints")
async def get_checkpoints(session_id: str) -> JSONResponse:
    """List all checkpoints for a session."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    checkpoints = _list_checkpoints(target)
    return JSONResponse(content=checkpoints)


@app.post("/api/sessions/{session_id}/checkpoints/{cp_id}/restore")
async def restore_checkpoint(session_id: str, cp_id: str) -> JSONResponse:
    """Restore a session to a specific checkpoint."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not re.match(r"^[a-zA-Z0-9._-]+$", cp_id):
        return JSONResponse(status_code=400, content={"error": "Invalid checkpoint ID"})

    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    cp_dir = _get_checkpoints_dir(target) / cp_id
    if not cp_dir.is_dir():
        return JSONResponse(status_code=404, content={"error": "Checkpoint not found"})

    # Restore files from checkpoint snapshot
    snapshot_dir = cp_dir / "snapshot"
    if snapshot_dir.is_dir():
        import shutil
        # Copy snapshot files back to project root, preserving existing .loki dir
        for item in snapshot_dir.rglob("*"):
            if item.is_file():
                rel = item.relative_to(snapshot_dir)
                dest = target / rel
                dest.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(str(item), str(dest))

    description = f"Restored to checkpoint {cp_id}"
    meta_file = cp_dir / "meta.json"
    if meta_file.exists():
        try:
            meta = json.loads(meta_file.read_text())
            description = meta.get("description", description)
        except (json.JSONDecodeError, OSError):
            pass

    return JSONResponse(content={
        "restored": True,
        "checkpoint_id": cp_id,
        "description": description,
    })


# ---------------------------------------------------------------------------
# File Search (Sprint 3.3)
# ---------------------------------------------------------------------------


def _flatten_file_tree(nodes: list[dict], results: list[dict]) -> None:
    """Recursively flatten a file tree into a list of file entries."""
    for node in nodes:
        results.append({
            "path": node["path"],
            "name": node["name"],
            "type": node["type"],
            "size": node.get("size"),
        })
        if node.get("children"):
            _flatten_file_tree(node["children"], results)


@app.get("/api/sessions/{session_id}/files/search")
async def search_session_files(session_id: str, q: str = "") -> JSONResponse:
    """Search files in a session project by name/path."""
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    if not q.strip():
        return JSONResponse(content=[])

    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    tree = _build_file_tree(target)
    all_files: list[dict] = []
    _flatten_file_tree(tree, all_files)

    query = q.strip().lower()
    results = [
        f for f in all_files
        if query in f["path"].lower() or query in f["name"].lower()
    ]
    # Return at most 50 results, files first, then directories
    results.sort(key=lambda f: (f["type"] != "file", f["path"].lower()))
    return JSONResponse(content=results[:50])


# ---------------------------------------------------------------------------
# Change Preview (Sprint 3.2)
# ---------------------------------------------------------------------------


@app.post("/api/sessions/{session_id}/chat/preview")
async def preview_chat_changes(session_id: str, req: ChatRequest) -> JSONResponse:
    """Preview what changes a chat message would make (dry-run diff).

    Uses git diff to show what would change without actually applying.
    Returns structured diff data for the ChangePreview modal.
    """
    if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
        return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
    target = _find_session_dir(session_id)
    if target is None:
        return JSONResponse(status_code=404, content={"error": "Session not found"})

    # For the preview, we parse the current git status to generate mock diffs
    # showing the pending unstaged/staged changes. In production this would
    # invoke the LLM in dry-run mode, but for now we show actual pending changes.
    files: list[dict] = []
    total_add = 0
    total_del = 0

    try:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["git", "diff", "--numstat", "HEAD"],
            capture_output=True, text=True, cwd=str(target), timeout=10
        ))
        if result.returncode == 0 and result.stdout.strip():
            for line in result.stdout.strip().splitlines():
                parts = line.split("\t")
                if len(parts) == 3:
                    additions = int(parts[0]) if parts[0] != "-" else 0
                    deletions = int(parts[1]) if parts[1] != "-" else 0
                    filepath = parts[2]
                    total_add += additions
                    total_del += deletions

                    # Get the actual diff hunks for this file
                    diff_result = await loop.run_in_executor(None, lambda fp=filepath: subprocess.run(
                        ["git", "diff", "HEAD", "--", fp],
                        capture_output=True, text=True, cwd=str(target), timeout=10
                    ))
                    hunks = _parse_diff_hunks(diff_result.stdout if diff_result.returncode == 0 else "")

                    action = "modify"
                    files.append({
                        "path": filepath,
                        "action": action,
                        "additions": additions,
                        "deletions": deletions,
                        "hunks": hunks,
                    })

        # Also check for untracked files
        untracked_result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["git", "ls-files", "--others", "--exclude-standard"],
            capture_output=True, text=True, cwd=str(target), timeout=10
        ))
        if untracked_result.returncode == 0 and untracked_result.stdout.strip():
            for filepath in untracked_result.stdout.strip().splitlines()[:20]:
                try:
                    fpath = target / filepath
                    if fpath.is_file():
                        content = fpath.read_text(errors="replace")
                        line_count = len(content.splitlines())
                        total_add += line_count
                        lines = [
                            {"type": "add", "content": l, "new_line": i + 1}
                            for i, l in enumerate(content.splitlines()[:100])
                        ]
                        files.append({
                            "path": filepath,
                            "action": "add",
                            "additions": line_count,
                            "deletions": 0,
                            "hunks": [{"old_start": 0, "old_count": 0, "new_start": 1, "new_count": line_count, "lines": lines}],
                        })
                except (OSError, UnicodeDecodeError):
                    pass
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    return JSONResponse(content={
        "session_id": session_id,
        "message": req.message,
        "files": files,
        "total_additions": total_add,
        "total_deletions": total_del,
    })


def _parse_diff_hunks(diff_text: str) -> list[dict]:
    """Parse unified diff output into structured hunks."""
    hunks: list[dict] = []
    if not diff_text:
        return hunks

    current_hunk: Optional[dict] = None
    old_line = 0
    new_line = 0

    for line in diff_text.splitlines():
        if line.startswith("@@"):
            # Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
            if current_hunk:
                hunks.append(current_hunk)
            match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
            if match:
                old_line = int(match.group(1))
                new_line = int(match.group(3))
                current_hunk = {
                    "old_start": old_line,
                    "old_count": int(match.group(2) or 1),
                    "new_start": new_line,
                    "new_count": int(match.group(4) or 1),
                    "lines": [],
                }
            continue

        if current_hunk is None:
            continue

        if line.startswith("+"):
            current_hunk["lines"].append({
                "type": "add",
                "content": line[1:],
                "new_line": new_line,
            })
            new_line += 1
        elif line.startswith("-"):
            current_hunk["lines"].append({
                "type": "delete",
                "content": line[1:],
                "old_line": old_line,
            })
            old_line += 1
        elif line.startswith(" "):
            current_hunk["lines"].append({
                "type": "context",
                "content": line[1:],
                "old_line": old_line,
                "new_line": new_line,
            })
            old_line += 1
            new_line += 1

    if current_hunk:
        hunks.append(current_hunk)

    # Limit to 50 hunks max to prevent huge responses
    return hunks[:50]


# ---------------------------------------------------------------------------
# GitHub Issues & PRs (real gh CLI integration)
# ---------------------------------------------------------------------------


def _get_repo_from_remote(project_dir: str) -> Optional[str]:
    """Parse git remote origin URL to extract owner/repo."""
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, cwd=project_dir, timeout=10,
        )
        if result.returncode != 0:
            return None
        url = result.stdout.strip()
        # Handle SSH: git@github.com:owner/repo.git
        m = re.search(r"github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$", url)
        if m:
            return f"{m.group(1)}/{m.group(2)}"
        # Handle HTTPS: https://github.com/owner/repo.git
        m = re.search(r"github\.com/([^/]+)/([^/.]+?)(?:\.git)?$", url)
        if m:
            return f"{m.group(1)}/{m.group(2)}"
        return None
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return None


def _ensure_gh_auth() -> bool:
    """Check if gh CLI is installed and authenticated."""
    import shutil
    gh = shutil.which("gh")
    if not gh:
        return False
    try:
        result = subprocess.run(
            [gh, "auth", "status"],
            capture_output=True, text=True, timeout=10,
        )
        return result.returncode == 0
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return False


def _run_gh(args: list[str], cwd: Optional[str] = None, timeout: int = 30) -> subprocess.CompletedProcess:
    """Run a gh CLI command. Raises FileNotFoundError if gh is not installed."""
    import shutil
    gh = shutil.which("gh")
    if not gh:
        raise FileNotFoundError("gh CLI not found. Install it: https://cli.github.com/")
    return subprocess.run(
        [gh] + args,
        capture_output=True, text=True, cwd=cwd, timeout=timeout,
    )


class GitHubImportRequest(BaseModel):
    repo: str
    branch: str = "main"

    @field_validator("repo")
    @classmethod
    def validate_repo(cls, v: str) -> str:
        if not re.match(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$", v):
            raise ValueError("repo must be in 'owner/repo' format")
        return v


class GitHubReviewRequest(BaseModel):
    action: str
    body: str = ""

    @field_validator("action")
    @classmethod
    def validate_action(cls, v: str) -> str:
        allowed = {"approve", "request_changes", "comment"}
        if v not in allowed:
            raise ValueError(f"action must be one of: {', '.join(sorted(allowed))}")
        return v


class GitHubMergeRequest(BaseModel):
    method: str = "merge"

    @field_validator("method")
    @classmethod
    def validate_method(cls, v: str) -> str:
        allowed = {"merge", "squash", "rebase"}
        if v not in allowed:
            raise ValueError(f"method must be one of: {', '.join(sorted(allowed))}")
        return v


@app.post("/api/sessions/{session_id}/github/import")
async def github_import_repo(session_id: str, req: GitHubImportRequest) -> JSONResponse:
    """Clone a GitHub repo into the session directory."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    # Check if session directory already has files (beyond .loki/)
    existing = [f for f in target.iterdir() if f.name != ".loki"]
    if existing:
        return JSONResponse(status_code=400, content={
            "error": "Session already has files. Create a new session for importing a repo.",
        })

    if not _ensure_gh_auth():
        return JSONResponse(status_code=400, content={
            "error": "gh CLI not found or not authenticated. Run: gh auth login",
        })

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh(
                ["repo", "clone", req.repo, "."],
                cwd=str(target), timeout=120,
            )
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Clone failed").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        # Checkout the requested branch if not default
        if req.branch != "main" and req.branch != "master":
            await loop.run_in_executor(
                None, lambda: _run_git(target, "checkout", req.branch)
            )

        # Count files
        files_count = sum(1 for _ in target.rglob("*") if _.is_file() and ".git" not in _.parts)

        # Detect default branch
        branch_result = await loop.run_in_executor(
            None, lambda: _run_git(target, "rev-parse", "--abbrev-ref", "HEAD")
        )
        default_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else req.branch

        # After successful clone, trigger doc generation in background
        try:
            loki_cli = _find_loki_cli()
            if loki_cli:
                asyncio.create_task(_run_doc_generation(str(target), loki_cli))
        except Exception:
            logger.debug("Auto-doc generation skipped")

        return JSONResponse(content={
            "success": True,
            "files_count": files_count,
            "default_branch": default_branch,
        })
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Clone timed out (120s limit)"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Import failed: {exc}"})


@app.get("/api/sessions/{session_id}/github/issues")
async def github_list_issues(
    session_id: str,
    state: str = "open",
    labels: str = "",
    limit: int = 30,
) -> JSONResponse:
    """List GitHub issues for the session's repo."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    if state not in ("open", "closed", "all"):
        return JSONResponse(status_code=400, content={"error": "state must be open, closed, or all"})
    limit = min(max(1, limit), 100)

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "issue", "list",
            "--repo", repo,
            "--state", state,
            "--limit", str(limit),
            "--json", "number,title,body,state,labels,author,createdAt,updatedAt,comments",
        ]
        if labels:
            cmd.extend(["--label", labels])
        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Failed to list issues").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        issues = json.loads(result.stdout) if result.stdout.strip() else []
        return JSONResponse(content=issues)
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to list issues: {exc}"})


@app.get("/api/sessions/{session_id}/github/issues/{issue_number}")
async def github_get_issue(session_id: str, issue_number: int) -> JSONResponse:
    """Get a single GitHub issue with full details."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh([
                "issue", "view", str(issue_number),
                "--repo", repo,
                "--json", "number,title,body,state,labels,author,comments,assignees",
            ], cwd=str(target))
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Issue not found").strip()
            status = 404 if "not found" in error_msg.lower() else 400
            return JSONResponse(status_code=status, content={"error": error_msg})

        issue = json.loads(result.stdout)
        return JSONResponse(content=issue)
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get issue: {exc}"})


@app.get("/api/sessions/{session_id}/github/prs")
async def github_list_prs(
    session_id: str,
    state: str = "open",
    limit: int = 30,
) -> JSONResponse:
    """List pull requests for the session's repo."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    if state not in ("open", "closed", "merged", "all"):
        return JSONResponse(status_code=400, content={"error": "state must be open, closed, merged, or all"})
    limit = min(max(1, limit), 100)

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh([
                "pr", "list",
                "--repo", repo,
                "--state", state,
                "--limit", str(limit),
                "--json", "number,title,body,state,author,createdAt,headRefName,baseRefName,reviewDecision,statusCheckRollup,additions,deletions,changedFiles",
            ], cwd=str(target))
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Failed to list PRs").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        prs = json.loads(result.stdout) if result.stdout.strip() else []
        return JSONResponse(content=prs)
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to list PRs: {exc}"})


@app.get("/api/sessions/{session_id}/github/prs/{pr_number}")
async def github_get_pr(session_id: str, pr_number: int) -> JSONResponse:
    """Get a single PR with full details including reviews and comments."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh([
                "pr", "view", str(pr_number),
                "--repo", repo,
                "--json", "number,title,body,state,author,comments,reviews,files,additions,deletions,commits,"
                          "headRefName,baseRefName,changedFiles,reviewDecision,statusCheckRollup",
            ], cwd=str(target))
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "PR not found").strip()
            status = 404 if "not found" in error_msg.lower() else 400
            return JSONResponse(status_code=status, content={"error": error_msg})

        pr = json.loads(result.stdout)
        return JSONResponse(content=pr)
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get PR: {exc}"})


@app.get("/api/sessions/{session_id}/github/prs/{pr_number}/diff")
async def github_get_pr_diff(session_id: str, pr_number: int) -> JSONResponse:
    """Get the raw unified diff for a pull request."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh([
                "pr", "diff", str(pr_number),
                "--repo", repo,
            ], cwd=str(target), timeout=60)
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Failed to get diff").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        return JSONResponse(content={"diff": result.stdout})
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get PR diff: {exc}"})


@app.post("/api/sessions/{session_id}/github/issues/{issue_number}/fix")
async def github_fix_issue(session_id: str, issue_number: int) -> JSONResponse:
    """Trigger Loki to fix a GitHub issue: fetch issue, create branch, run fix, create PR."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    if not _ensure_gh_auth():
        return JSONResponse(status_code=400, content={
            "error": "gh CLI not found or not authenticated. Run: gh auth login",
        })

    # Create a background task and return immediately
    _cleanup_chat_tasks()
    task = ChatTask()
    _chat_tasks[task.id] = task

    async def run_fix_issue() -> None:
        loop = asyncio.get_running_loop()
        proc: Optional[subprocess.Popen] = None
        try:
            # 1. Fetch issue details
            issue_result = await loop.run_in_executor(
                None, lambda: _run_gh([
                    "issue", "view", str(issue_number),
                    "--repo", repo,
                    "--json", "number,title,body,state,labels",
                ], cwd=str(target))
            )
            if issue_result.returncode != 0:
                task.output_lines.append(f"Failed to fetch issue #{issue_number}: {issue_result.stderr}")
                task.returncode = 1
                task.complete = True
                return

            issue_data = json.loads(issue_result.stdout)
            issue_title = issue_data.get("title", f"Issue #{issue_number}")
            issue_body = issue_data.get("body", "")

            task.output_lines.append(f"Fetched issue #{issue_number}: {issue_title}")

            # 2. Create a fix branch
            branch_name = f"fix/issue-{issue_number}"
            # Ensure we are on the default branch first
            await loop.run_in_executor(
                None, lambda: _run_git(target, "checkout", "main")
            )
            # Pull latest
            await loop.run_in_executor(
                None, lambda: _run_git(target, "pull", "--ff-only", timeout=30)
            )
            # Create and checkout the fix branch
            branch_result = await loop.run_in_executor(
                None, lambda: _run_git(target, "checkout", "-b", branch_name)
            )
            if branch_result.returncode != 0:
                # Branch might already exist, try switching to it
                await loop.run_in_executor(
                    None, lambda: _run_git(target, "checkout", branch_name)
                )

            task.output_lines.append(f"Created branch: {branch_name}")

            # 3. Run loki quick to fix the issue
            loki = _find_loki_cli()
            if loki is None:
                task.output_lines.append("loki CLI not found")
                task.returncode = 1
                task.complete = True
                return

            fix_prompt = (
                f"Fix GitHub issue #{issue_number}: {issue_title}\n\n"
                f"{issue_body}\n\n"
                f"Make the necessary code changes to fix this issue. "
                f"Ensure all changes are tested and working."
            )

            fix_env = {**os.environ}
            fix_env.update(_load_secrets())
            # Detect provider from session state
            fix_provider = session.provider or "claude"
            prov_file = target / ".loki" / "state" / "provider"
            if prov_file.exists():
                try:
                    _fp = prov_file.read_text().strip()
                    if _fp:
                        fix_provider = _fp
                except OSError:
                    pass
            fix_env["LOKI_PROVIDER"] = fix_provider

            task.output_lines.append(f"Running loki quick with provider: {fix_provider}")

            proc = subprocess.Popen(
                [loki, "quick", fix_prompt],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=str(target),
                env=fix_env,
                start_new_session=True,
            )
            task.process = proc
            _track_child_pid(proc.pid)

            def _read_output() -> None:
                assert proc.stdout is not None
                for raw_line in proc.stdout:
                    if task.cancelled:
                        break
                    clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
                    stripped = clean.strip()
                    # Skip tool invocation noise
                    if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
                                    "[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
                                    "[Result]", "[Thinking]"):
                        continue
                    if not stripped:
                        continue
                    task.output_lines.append(clean)
                proc.stdout.close()

            await asyncio.wait_for(loop.run_in_executor(None, _read_output), timeout=600)
            proc.wait(timeout=10)

            if proc.returncode != 0:
                task.output_lines.append(f"loki quick exited with code {proc.returncode}")
                task.returncode = proc.returncode
                task.complete = True
                return

            task.output_lines.append("Fix applied. Committing and pushing changes...")

            # 4. Stage, commit, and push
            await loop.run_in_executor(
                None, lambda: _run_git(target, "add", "-A")
            )
            commit_msg = f"fix: #{issue_number} {issue_title}"
            commit_result = await loop.run_in_executor(
                None, lambda: _run_git(target, "commit", "-m", commit_msg, timeout=30)
            )
            if commit_result.returncode != 0:
                # Nothing to commit -- changes may have been committed by loki
                task.output_lines.append("No additional changes to commit (loki may have committed already)")

            push_result = await loop.run_in_executor(
                None, lambda: _run_git(target, "push", "-u", "origin", branch_name, timeout=60)
            )
            if push_result.returncode != 0:
                task.output_lines.append(f"Push failed: {push_result.stderr.strip()}")
                task.returncode = 1
                task.complete = True
                return

            task.output_lines.append(f"Pushed {branch_name} to origin")

            # 5. Create a PR
            pr_body = f"Fixes #{issue_number}\n\nAI-generated fix by Loki Mode"
            pr_result = await loop.run_in_executor(
                None, lambda: _run_gh([
                    "pr", "create",
                    "--title", f"fix: #{issue_number} {issue_title}",
                    "--body", pr_body,
                    "--repo", repo,
                ], cwd=str(target), timeout=30)
            )
            if pr_result.returncode != 0:
                task.output_lines.append(f"PR creation failed: {pr_result.stderr.strip()}")
                task.returncode = 1
                task.complete = True
                return

            pr_url = pr_result.stdout.strip()
            pr_num = 0
            if pr_url:
                parts = pr_url.rstrip("/").split("/")
                try:
                    pr_num = int(parts[-1])
                except (ValueError, IndexError):
                    pass

            task.output_lines.append(f"Created PR: {pr_url}")
            # Store PR info in task for retrieval
            task.files_changed = [f"branch:{branch_name}", f"pr_url:{pr_url}", f"pr_number:{pr_num}"]
            task.returncode = 0

        except asyncio.TimeoutError:
            if proc is not None:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    if proc.poll() is None:
                        proc.kill()
                proc.wait()
            task.output_lines.append("Fix timed out after 10 minutes")
            task.returncode = 1
        except Exception as e:
            task.output_lines.append(f"Error: {e}")
            task.returncode = 1
            if proc is not None and proc.poll() is None:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    proc.kill()
                proc.wait()
        finally:
            if proc is not None:
                _untrack_child_pid(proc.pid)
            task.complete = True

    asyncio.create_task(run_fix_issue())

    return JSONResponse(content={
        "task_id": task.id,
        "status": "running",
        "message": f"Fixing issue #{issue_number} in background",
    })


@app.post("/api/sessions/{session_id}/github/prs/{pr_number}/review")
async def github_review_pr(session_id: str, pr_number: int, req: GitHubReviewRequest) -> JSONResponse:
    """Submit a review on a pull request."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    loop = asyncio.get_running_loop()
    try:
        # Map action to gh flag
        action_flag = f"--{req.action.replace('_', '-')}"
        cmd = [
            "pr", "review", str(pr_number),
            "--repo", repo,
            action_flag,
        ]
        if req.body:
            cmd.extend(["--body", req.body])

        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Review failed").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        return JSONResponse(content={"success": True})
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Review failed: {exc}"})


@app.post("/api/sessions/{session_id}/github/prs/{pr_number}/merge")
async def github_merge_pr(session_id: str, pr_number: int, req: GitHubMergeRequest) -> JSONResponse:
    """Merge a pull request."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(str(target))
    if not repo:
        return JSONResponse(status_code=400, content={
            "error": "No GitHub remote found. Push the project to GitHub first.",
        })

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None, lambda: _run_gh([
                "pr", "merge", str(pr_number),
                "--repo", repo,
                f"--{req.method}",
            ], cwd=str(target))
        )
        if result.returncode != 0:
            error_msg = (result.stderr or result.stdout or "Merge failed").strip()
            return JSONResponse(status_code=400, content={"error": error_msg})

        # Try to get the merge commit SHA
        sha = ""
        merge_output = (result.stdout or "").strip()
        sha_match = re.search(r"[0-9a-f]{40}", merge_output)
        if sha_match:
            sha = sha_match.group(0)

        return JSONResponse(content={"success": True, "sha": sha})
    except FileNotFoundError:
        return JSONResponse(status_code=400, content={"error": "gh CLI not found. Install it: https://cli.github.com/"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Request timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Merge failed: {exc}"})


# ---------------------------------------------------------------------------
# Teams & RBAC endpoints
# ---------------------------------------------------------------------------

# File-based persistence for teams and audit log
_TEAMS_FILE = ".loki/teams.json"
_AUDIT_LOG_FILE = ".loki/audit-log.json"


def _ensure_loki_dir() -> None:
    """Create .loki/ directory if it does not exist."""
    loki_dir = _loki_dir()
    loki_dir.mkdir(parents=True, exist_ok=True)


def _load_teams() -> dict:
    """Read teams from .loki/teams.json. Returns empty dict if not found."""
    teams_path = _loki_dir() / "teams.json"
    try:
        return json.loads(teams_path.read_text(encoding="utf-8"))
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _save_teams(data: dict) -> None:
    """Write teams to .loki/teams.json atomically (write to temp, then rename)."""
    _ensure_loki_dir()
    teams_path = _loki_dir() / "teams.json"
    tmp_path = teams_path.with_suffix(".json.tmp")
    tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
    os.replace(str(tmp_path), str(teams_path))


def _load_audit_log() -> list:
    """Read audit log from .loki/audit-log.json. Returns empty list if not found."""
    audit_path = _loki_dir() / "audit-log.json"
    try:
        return json.loads(audit_path.read_text(encoding="utf-8"))
    except (FileNotFoundError, json.JSONDecodeError):
        return []


def _append_audit_log(entry: dict) -> None:
    """Append an entry to .loki/audit-log.json atomically."""
    _ensure_loki_dir()
    audit_path = _loki_dir() / "audit-log.json"
    log = _load_audit_log()
    log.insert(0, entry)
    # Keep last 500 entries
    if len(log) > 500:
        log = log[:500]
    tmp_path = audit_path.with_suffix(".json.tmp")
    tmp_path.write_text(json.dumps(log, indent=2), encoding="utf-8")
    os.replace(str(tmp_path), str(audit_path))


class CreateTeamRequest(BaseModel):
    name: str


class AddMemberRequest(BaseModel):
    email: str
    # NOT AN AUTHORIZATION CONTROL. This value is stored on the member record
    # and echoed back; nothing in this server reads it to permit or deny
    # anything (measured: the only other `role` occurrences in this file are
    # chat-message roles around line 4166). /api/teams, /api/teams/{id}/members
    # and /api/audit-log carry no auth dependency at all, and web-app/auth.py:1-4
    # states that without DATABASE_URL authentication is completely disabled and
    # every endpoint is open.
    #
    # It is kept because the UI displays it and removing it would break that
    # surface, but a field that LOOKS like a permission and enforces nothing is
    # worse than no field: a buyer may rely on it. Wiring real enforcement means
    # adding an auth dependency to these routes first; until then this label is
    # descriptive only.
    role: str = "viewer"


def _audit_actor(request=None) -> tuple:
    """Resolve WHO performed an action, honestly.

    Returns (actor, actor_state). actor_state is one of:
      "identified"     - an authenticated principal; actor is their id
      "unauthenticated"- auth is not configured, so the actor is genuinely
                         unknowable. web-app/auth.py:1-4 states that with no
                         DATABASE_URL, authentication is completely disabled and
                         all endpoints are open. That is the DEFAULT.
      "unreadable"     - an actor should have been resolvable but the lookup
                         failed; recorded so it is never mistaken for the above

    Why this exists: _audit() previously hardcoded user="system" for every
    entry. An audit trail that cannot say who did something is not an audit
    trail, and "system" is worse than blank because it asserts an actor that was
    never established. Never invent an actor; say which of the three states
    applies and let the reader judge.
    """
    if request is None:
        return ("unknown", "unauthenticated")
    try:
        principal = getattr(request.state, "user", None)
        if isinstance(principal, dict):
            ident = principal.get("sub") or principal.get("email") or principal.get("id")
            if ident:
                return (str(ident), "identified")
        elif principal:
            return (str(principal), "identified")
        return ("unknown", "unauthenticated")
    except Exception:  # noqa: BLE001 - never let attribution break the action
        return ("unknown", "unreadable")


def _audit(action: str, user: str = "", target: str = "", details: str = "",
           request=None, team_id: str = ""):
    """Record an audit log entry.

    NOTE ON WHAT THIS LOG IS: _append_audit_log rewrites a 500-entry JSON array
    in place on every call. It is a recent-activity view, NOT tamper-evident and
    NOT append-only, and it is a different thing from the hash-chained log in
    dashboard/audit.py. Do not cite it as audit evidence. See
    docs/AUDIT-CHAIN-THREAT-MODEL.md.
    """
    if user:
        actor, actor_state = (user, "identified")
    else:
        actor, actor_state = _audit_actor(request)
    entry = {
        "id": str(uuid.uuid4()),
        "action": action,
        "user": actor,
        "actor_state": actor_state,
        "target": target,
        "timestamp": datetime.now().isoformat(),
        "details": details,
    }
    # The Teams page Activity tab lists only entries carrying its team's id;
    # a target (a team name or an email) is not a team identity.
    if team_id:
        entry["team_id"] = team_id
    _append_audit_log(entry)


@app.get("/api/teams")
async def list_teams() -> JSONResponse:
    """List all teams."""
    teams = list(_load_teams().values())
    return JSONResponse(content=teams)


@app.post("/api/teams")
async def create_team(req: CreateTeamRequest, request: Request = None) -> JSONResponse:
    """Create a new team."""
    team_id = f"team-{uuid.uuid4().hex[:8]}"
    team = {
        "id": team_id,
        "name": req.name,
        "members": [],
        "created_at": datetime.now().isoformat(),
    }
    store = _load_teams()
    store[team_id] = team
    _save_teams(store)
    _audit("team.created", target=req.name, request=request, team_id=team_id)
    return JSONResponse(content={"id": team_id, "name": req.name, "created": True})


@app.get("/api/teams/{team_id}/members")
async def list_team_members(team_id: str) -> JSONResponse:
    """List members of a team."""
    store = _load_teams()
    team = store.get(team_id)
    if not team:
        return JSONResponse(status_code=404, content={"error": "Team not found"})
    return JSONResponse(content=team.get("members", []))


@app.post("/api/teams/{team_id}/members")
async def add_team_member(team_id: str, req: AddMemberRequest, request: Request = None) -> JSONResponse:
    """Add a member to a team."""
    store = _load_teams()
    team = store.get(team_id)
    if not team:
        return JSONResponse(status_code=404, content={"error": "Team not found"})
    member_id = f"m-{uuid.uuid4().hex[:8]}"
    member = {
        "id": member_id,
        "email": req.email,
        "name": req.email.split("@")[0],
        "role": req.role,
        "joined_at": datetime.now().isoformat(),
    }
    team.setdefault("members", []).append(member)
    _save_teams(store)
    _audit("member.added", target=req.email, details=f"Role: {req.role}", request=request,
           team_id=team_id)
    return JSONResponse(content={"added": True, "member_id": member_id})


@app.get("/api/audit-log")
async def get_audit_log() -> JSONResponse:
    """Get audit log entries."""
    return JSONResponse(content=_load_audit_log())


# ---------------------------------------------------------------------------
# Static file serving (built React app)
# IMPORTANT: serve_spa was previously defined here at line 7725, BEFORE the
# /api/magic, /api/deploy, /api/sessions/.../github/actions, and
# /api/sessions/.../docs route registrations. FastAPI registers routes in
# definition order, so the catch-all '/{full_path:path}' silently swallowed
# those 22 endpoints (they returned index.html instead of JSON). Moved to
# the very end of the file just before standalone_app in v7.6.0 so all
# specific API routes register first.
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Magic Modules API (/api/magic/*)
# ---------------------------------------------------------------------------


def _magic_registry_path() -> Path:
    """Locate the magic registry for the active project (cwd-based)."""
    return Path.cwd() / ".loki" / "magic" / "registry.json"


@app.get("/api/magic/components")
async def magic_list_components() -> JSONResponse:
    """Return the current magic component registry."""
    reg_path = _magic_registry_path()
    if not reg_path.exists():
        return JSONResponse(content={"count": 0, "components": []})
    try:
        data = json.loads(reg_path.read_text())
        components = data.get("components", [])
        return JSONResponse(content={"count": len(components), "components": components})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": str(exc)})


@app.post("/api/magic/generate")
async def magic_generate(req: dict = Body(...)) -> JSONResponse:
    """Generate a new component via `loki magic generate`."""
    name = (req.get("name") or "").strip()
    description = (req.get("description") or "").strip()
    target = (req.get("target") or "react").strip()
    tags = req.get("tags") or []

    if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid component name"})
    if target not in ("react", "webcomponent", "both"):
        return JSONResponse(status_code=400, content={"error": "target must be react|webcomponent|both"})

    loki_cli = _find_loki_cli()
    if not loki_cli:
        return JSONResponse(status_code=500, content={"error": "loki CLI not found"})

    cmd = [str(loki_cli), "magic", "generate", name, "--target", target]
    if description:
        cmd.extend(["--description", description])
    if tags:
        cmd.extend(["--tags", ",".join(str(t) for t in tags)])

    try:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(
            None,
            lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=str(Path.cwd())),
        )
        if result.returncode == 0:
            return JSONResponse(content={"success": True, "name": name, "stdout": result.stdout[-2000:]})
        return JSONResponse(status_code=500, content={"error": result.stderr[-2000:] or "generation failed"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "generation timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": str(exc)})


@app.get("/api/magic/components/{name}/spec")
async def magic_get_spec(name: str) -> JSONResponse:
    """Return the markdown spec for a component."""
    if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid component name"})
    spec_path = Path.cwd() / ".loki" / "magic" / "specs" / f"{name}.md"
    if not spec_path.exists():
        return JSONResponse(status_code=404, content={"error": "spec not found"})
    return JSONResponse(content={"name": name, "markdown": spec_path.read_text()})


@app.get("/api/magic/components/{name}/code")
async def magic_get_code(name: str) -> JSONResponse:
    """Return a component's generated source, read from the paths its registry entry records."""
    if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid component name"})
    reg_path = _magic_registry_path()
    if not reg_path.exists():
        return JSONResponse(status_code=404, content={"error": "no registry"})
    try:
        data = json.loads(reg_path.read_text())
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": str(exc)})
    comps = data.get("components", []) if isinstance(data, dict) else []
    entry = next((c for c in comps if isinstance(c, dict) and c.get("name") == name), None)
    if entry is None:
        return JSONResponse(status_code=404, content={"error": "component not found"})
    files = []
    for target in ("react", "webcomponent"):
        recorded = entry.get(f"{target}_path") or ""
        if not isinstance(recorded, str) or not recorded:
            continue
        # registry.json is a project file an agent can write; a recorded path
        # (or a symlink) that leaves the project is refused, never read.
        path = _safe_resolve(Path.cwd(), recorded)
        if path is None:
            return JSONResponse(status_code=403, content={"error": f"{target} path is outside the project"})
        if path.is_file():
            files.append({"target": target, "path": recorded, "code": path.read_text(errors="replace")})
    if not files:
        return JSONResponse(status_code=404, content={"error": "no generated code on disk"})
    return JSONResponse(content={"name": name, "files": files})


@app.post("/api/magic/components/{name}/debate")
async def magic_run_debate(name: str, req: dict = Body(default={})) -> JSONResponse:
    """Trigger a multi-persona debate on an existing component."""
    if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid component name"})
    rounds = int(req.get("rounds", 3))
    loki_cli = _find_loki_cli()
    if not loki_cli:
        return JSONResponse(status_code=500, content={"error": "loki CLI not found"})
    cmd = [str(loki_cli), "magic", "debate", name, "--rounds", str(rounds)]
    try:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(
            None,
            lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=600, cwd=str(Path.cwd())),
        )
        return JSONResponse(content={"success": result.returncode == 0, "stdout": result.stdout[-4000:], "stderr": result.stderr[-2000:]})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": str(exc)})


@app.delete("/api/magic/components/{name}")
async def magic_deprecate(name: str) -> JSONResponse:
    """Deprecate a component (does not delete files)."""
    if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
        return JSONResponse(status_code=400, content={"error": "Invalid component name"})
    reg_path = _magic_registry_path()
    if not reg_path.exists():
        return JSONResponse(status_code=404, content={"error": "no registry"})
    try:
        data = json.loads(reg_path.read_text())
        found = False
        for comp in data.get("components", []):
            if comp.get("name") == name:
                comp["deprecated"] = True
                found = True
                break
        if not found:
            return JSONResponse(status_code=404, content={"error": "component not found"})
        tmp = reg_path.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(data, indent=2))
        tmp.replace(reg_path)
        return JSONResponse(content={"success": True, "name": name})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": str(exc)})


# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------


def main() -> None:
    import uvicorn
    host = os.environ.get("PURPLE_LAB_HOST", HOST)
    port = int(os.environ.get("PURPLE_LAB_PORT", str(PORT)))
    print(f"Purple Lab starting on http://{host}:{port}/lab/")
    # Use standalone_app (defined below) so the rebased /lab/ bundle routes
    # correctly through the same FastAPI handlers used in dashboard's mount.
    uvicorn.run(standalone_app, host=host, port=port, log_level="info", timeout_keep_alive=30)


_TOKENS_DIR = SCRIPT_DIR.parent / ".loki" / "tokens"


def _token_path(platform: str) -> Path:
    """Return the path to a platform's token file."""
    return _TOKENS_DIR / f"{platform}.json"


def _load_token(platform: str) -> Optional[dict]:
    """Load a stored token for a platform. Returns dict or None."""
    path = _token_path(platform)
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text())
        if isinstance(data, dict) and data.get("token"):
            return data
    except (json.JSONDecodeError, OSError):
        pass
    return None


def _save_token(platform: str, token: str, user: str) -> None:
    """Save a verified token for a platform with secure file permissions."""
    _TOKENS_DIR.mkdir(parents=True, exist_ok=True)
    data = {
        "token": token,
        "user": user,
        "created_at": datetime.utcnow().isoformat() + "Z",
        "verified": True,
    }
    path = _token_path(platform)
    path.write_text(json.dumps(data, indent=2))
    os.chmod(str(path), 0o600)


def _delete_token(platform: str) -> bool:
    """Delete a stored token. Returns True if deleted, False if not found."""
    path = _token_path(platform)
    if path.exists():
        path.unlink()
        return True
    return False


# ---------------------------------------------------------------------------
# Deploy connection endpoints (token management)
# ---------------------------------------------------------------------------


@app.post("/api/deploy/vercel/token")
async def set_vercel_token(req: dict = Body(...)) -> JSONResponse:
    """Store a Vercel token after verifying it with the Vercel CLI."""
    token = req.get("token", "").strip()
    if not token:
        return JSONResponse(status_code=400, content={"error": "Token is required"})

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["vercel", "whoami", "--token", token],
            capture_output=True, text=True, timeout=15,
        ))
        output = (result.stdout or "").strip()
        if result.returncode != 0:
            stderr = (result.stderr or "").strip()
            return JSONResponse(status_code=400, content={
                "error": "Token verification failed",
                "detail": stderr or output or "vercel whoami returned non-zero",
            })
        # The output of `vercel whoami` is the username
        user = output.split("\n")[-1].strip() if output else "unknown"
        _save_token("vercel", token, user)
        return JSONResponse(content={"success": True, "user": user})
    except FileNotFoundError:
        return JSONResponse(status_code=500, content={
            "error": "Vercel CLI not found. Install with: npm i -g vercel"
        })
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Token verification timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Token verification failed: {exc}"})


@app.get("/api/deploy/vercel/status")
async def get_vercel_status() -> JSONResponse:
    """Check if a Vercel token is configured and valid."""
    data = _load_token("vercel")
    if not data:
        return JSONResponse(content={"connected": False})

    # Re-verify the token is still valid
    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["vercel", "whoami", "--token", data["token"]],
            capture_output=True, text=True, timeout=15,
        ))
        if result.returncode == 0:
            user = (result.stdout or "").strip().split("\n")[-1].strip() or data.get("user", "unknown")
            return JSONResponse(content={"connected": True, "user": user})
        else:
            return JSONResponse(content={"connected": False, "error": "Token expired or revoked"})
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        # CLI not available or timeout -- report based on stored data
        return JSONResponse(content={
            "connected": True,
            "user": data.get("user", "unknown"),
            "warning": "Could not re-verify token (CLI unavailable or timeout)",
        })


@app.post("/api/deploy/netlify/token")
async def set_netlify_token(req: dict = Body(...)) -> JSONResponse:
    """Store a Netlify personal access token after verifying it."""
    token = req.get("token", "").strip()
    if not token:
        return JSONResponse(status_code=400, content={"error": "Token is required"})

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["netlify", "status", "--auth", token],
            capture_output=True, text=True, timeout=15,
        ))
        output = (result.stdout or "") + (result.stderr or "")
        if result.returncode != 0:
            return JSONResponse(status_code=400, content={
                "error": "Token verification failed",
                "detail": output.strip() or "netlify status returned non-zero",
            })
        # Extract username from netlify status output
        user = "unknown"
        for line in output.split("\n"):
            # Netlify status shows "Email: user@example.com" or "Name: ..."
            if "Email:" in line:
                user = line.split("Email:")[-1].strip()
                break
            elif "Name:" in line and user == "unknown":
                user = line.split("Name:")[-1].strip()
        _save_token("netlify", token, user)
        return JSONResponse(content={"success": True, "user": user})
    except FileNotFoundError:
        return JSONResponse(status_code=500, content={
            "error": "Netlify CLI not found. Install with: npm i -g netlify-cli"
        })
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=500, content={"error": "Token verification timed out"})
    except Exception as exc:
        return JSONResponse(status_code=500, content={"error": f"Token verification failed: {exc}"})


@app.get("/api/deploy/netlify/status")
async def get_netlify_status() -> JSONResponse:
    """Check if a Netlify token is configured and valid."""
    data = _load_token("netlify")
    if not data:
        return JSONResponse(content={"connected": False})

    # Re-verify the token
    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["netlify", "status", "--auth", data["token"]],
            capture_output=True, text=True, timeout=15,
        ))
        if result.returncode == 0:
            return JSONResponse(content={"connected": True, "user": data.get("user", "unknown")})
        else:
            return JSONResponse(content={"connected": False, "error": "Token expired or revoked"})
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return JSONResponse(content={
            "connected": True,
            "user": data.get("user", "unknown"),
            "warning": "Could not re-verify token (CLI unavailable or timeout)",
        })


@app.get("/api/deploy/github/status")
async def get_github_status() -> JSONResponse:
    """Check if GitHub CLI (gh) is authenticated."""
    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(None, lambda: subprocess.run(
            ["gh", "auth", "status"],
            capture_output=True, text=True, timeout=15,
        ))
        output = (result.stdout or "") + (result.stderr or "")
        if result.returncode == 0:
            # Extract username from gh auth status output
            user = "unknown"
            for line in output.split("\n"):
                if "Logged in to" in line:
                    # "Logged in to github.com account username ..."
                    parts = line.split("account")
                    if len(parts) > 1:
                        user = parts[1].strip().split()[0].strip("()")
                        break
            return JSONResponse(content={"connected": True, "user": user})
        else:
            return JSONResponse(content={"connected": False})
    except FileNotFoundError:
        return JSONResponse(content={"connected": False, "error": "gh CLI not installed"})
    except subprocess.TimeoutExpired:
        return JSONResponse(content={"connected": False, "error": "Auth check timed out"})
    except Exception as exc:
        return JSONResponse(content={"connected": False, "error": str(exc)})


@app.get("/api/deploy/status")
async def get_all_deploy_status() -> JSONResponse:
    """Return connection status for all deployment platforms."""
    loop = asyncio.get_running_loop()

    async def _check_vercel() -> dict:
        data = _load_token("vercel")
        if not data:
            return {"connected": False}
        try:
            result = await loop.run_in_executor(None, lambda: subprocess.run(
                ["vercel", "whoami", "--token", data["token"]],
                capture_output=True, text=True, timeout=10,
            ))
            if result.returncode == 0:
                user = (result.stdout or "").strip().split("\n")[-1].strip() or data.get("user", "unknown")
                return {"connected": True, "user": user}
            return {"connected": False, "error": "Token expired or revoked"}
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            return {"connected": True, "user": data.get("user", "unknown"), "warning": "Could not re-verify"}

    async def _check_netlify() -> dict:
        data = _load_token("netlify")
        if not data:
            return {"connected": False}
        try:
            result = await loop.run_in_executor(None, lambda: subprocess.run(
                ["netlify", "status", "--auth", data["token"]],
                capture_output=True, text=True, timeout=10,
            ))
            if result.returncode == 0:
                return {"connected": True, "user": data.get("user", "unknown")}
            return {"connected": False, "error": "Token expired or revoked"}
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            return {"connected": True, "user": data.get("user", "unknown"), "warning": "Could not re-verify"}

    # THE DEFECT this closes: the two coroutines above were defined and then
    # never awaited. The function body ended here with no gather and no return,
    # so FastAPI serialized None. A null body is the worst failure shape
    # available: res.ok is true and the content-type is JSON, so the client
    # raises nothing, renders every platform as disconnected, and re-polls
    # every 30s forever with no error to diagnose.
    #
    # THREE keys, not two: types/api.ts:232 declares github alongside vercel
    # and netlify. Returning only the two that had local coroutines would leave
    # statuses.github undefined in a UI that reads it. get_github_status
    # (defined below) is the ready-made third.
    vercel, netlify = await asyncio.gather(_check_vercel(), _check_netlify())
    try:
        github_resp = await get_github_status()
        github = json.loads(bytes(github_resp.body).decode("utf-8"))
    except Exception:
        # Never let the third checker take down the two that succeeded. An
        # honest "unknown" beats a 500 that hides working platforms.
        github = {"connected": False, "error": "Could not determine GitHub status"}

    return JSONResponse(content={
        "vercel": vercel,
        "netlify": netlify,
        "github": github,
    })


@app.post("/api/deploy/{platform}/disconnect")
async def disconnect_platform(platform: str) -> JSONResponse:
    """Remove a stored platform token.

    SECURITY: _token_path interpolates `platform` directly into a filename
    (f"{platform}.json"), so an unvalidated value here is a file-delete
    primitive -- "../../something" would escape _TOKENS_DIR. The whitelist is
    the control, applied BEFORE the path is ever constructed.

    `github` is deliberately refused rather than silently accepted: it holds no
    token file, so reporting success would tell the user we disconnected
    something we never touched.
    """
    if platform not in ("vercel", "netlify"):
        return JSONResponse(
            status_code=400,
            content={"error": f"Unknown platform '{platform}'. Expected vercel or netlify."},
        )

    path = _token_path(platform)
    existed = path.exists()
    if existed:
        try:
            path.unlink()
        except OSError as exc:
            return JSONResponse(
                status_code=500,
                content={"error": f"Could not remove the stored {platform} token: {exc}"},
            )

    # `existed` is reported rather than swallowed so a no-op disconnect is
    # distinguishable from a real one.
    return JSONResponse(content={"disconnected": True, "platform": platform, "had_token": existed})


# ---------------------------------------------------------------------------
# GitHub Actions endpoints (CI/CD panel)
# ---------------------------------------------------------------------------


def _get_repo_from_remote(project_dir: Path) -> Optional[str]:
    """Extract 'owner/repo' from the git remote origin URL.

    Supports both HTTPS and SSH remote formats:
      https://github.com/owner/repo.git -> owner/repo
      git@github.com:owner/repo.git     -> owner/repo
    """
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, cwd=str(project_dir), timeout=10,
        )
        if result.returncode != 0:
            return None
        remote = result.stdout.strip()
        # HTTPS: https://github.com/owner/repo.git
        m = re.search(r"github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$", remote)
        if m:
            return f"{m.group(1)}/{m.group(2)}"
        return None
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return None


def _run_gh(args: list[str], cwd: str, timeout: int = 30) -> subprocess.CompletedProcess:
    """Run a gh CLI command. Raises FileNotFoundError if gh is not installed."""
    import shutil
    gh = shutil.which("gh")
    if not gh:
        raise FileNotFoundError("gh CLI not found. Install it: https://cli.github.com/")
    return subprocess.run(
        [gh] + args,
        capture_output=True, text=True, cwd=cwd, timeout=timeout,
    )


@app.get("/api/sessions/{session_id}/github/actions/runs")
async def github_actions_list_runs(
    session_id: str, limit: int = 10, branch: Optional[str] = None,
) -> JSONResponse:
    """List recent workflow runs for the session's repo."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    limit = min(max(limit, 1), 100)
    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "run", "list",
            "--repo", repo,
            "--limit", str(limit),
            "--json", "databaseId,name,status,conclusion,headBranch,event,createdAt,updatedAt,url,workflowName",
        ]
        if branch:
            cmd.extend(["--branch", branch])

        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            if "not logged" in error_msg.lower() or "auth" in error_msg.lower():
                return JSONResponse(status_code=401, content={"error": "gh CLI not authenticated. Run: gh auth login"})
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to list workflow runs"})

        runs = json.loads(result.stdout) if result.stdout.strip() else []
        return JSONResponse(content=runs)
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "GitHub API request timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to list runs: {exc}"})


@app.get("/api/sessions/{session_id}/github/actions/runs/{run_id}")
async def github_actions_run_detail(session_id: str, run_id: int) -> JSONResponse:
    """Get detailed info for a specific workflow run including jobs and steps."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "run", "view", str(run_id),
            "--repo", repo,
            "--json", "name,status,conclusion,jobs",
        ]
        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            if "not found" in error_msg.lower() or "could not find" in error_msg.lower():
                return JSONResponse(status_code=404, content={"error": f"Workflow run {run_id} not found"})
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to get run details"})

        detail = json.loads(result.stdout) if result.stdout.strip() else {}
        return JSONResponse(content=detail)
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "GitHub API request timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get run detail: {exc}"})


@app.get("/api/sessions/{session_id}/github/actions/runs/{run_id}/logs")
async def github_actions_run_logs(session_id: str, run_id: int) -> JSONResponse:
    """Get logs for a specific workflow run (truncated to last 5000 lines if huge)."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "run", "view", str(run_id),
            "--repo", repo,
            "--log",
        ]
        result = await loop.run_in_executor(
            None, lambda: _run_gh(cmd, cwd=str(target), timeout=60),
        )
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            if "not found" in error_msg.lower() or "could not find" in error_msg.lower():
                return JSONResponse(status_code=404, content={"error": f"Logs for run {run_id} not found"})
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to get run logs"})

        log_text = result.stdout or ""
        # Truncate to last 5000 lines if too large
        lines = log_text.splitlines()
        if len(lines) > 5000:
            log_text = "\n".join(lines[-5000:])

        return JSONResponse(content={"logs": log_text})
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "Log retrieval timed out (logs may be very large)"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to get run logs: {exc}"})


@app.get("/api/sessions/{session_id}/github/actions/workflows")
async def github_actions_list_workflows(session_id: str) -> JSONResponse:
    """List available workflows for the session's repo."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "workflow", "list",
            "--repo", repo,
            "--json", "name,id,state",
        ]
        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to list workflows"})

        workflows = json.loads(result.stdout) if result.stdout.strip() else []
        return JSONResponse(content=workflows)
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except json.JSONDecodeError:
        return JSONResponse(status_code=500, content={"error": "Failed to parse gh output"})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "GitHub API request timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to list workflows: {exc}"})


@app.post("/api/sessions/{session_id}/github/actions/dispatch")
async def github_actions_dispatch(session_id: str, req: dict = Body(...)) -> JSONResponse:
    """Dispatch (trigger) a workflow run."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    workflow = req.get("workflow", "").strip()
    if not workflow:
        return JSONResponse(status_code=400, content={"error": "workflow is required"})

    ref = req.get("ref", "main").strip()
    inputs = req.get("inputs", {})

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "workflow", "run", workflow,
            "--repo", repo,
            "--ref", ref,
        ]
        if isinstance(inputs, dict):
            for key, value in inputs.items():
                cmd.extend(["--field", f"{key}={value}"])

        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to dispatch workflow"})

        return JSONResponse(content={"success": True, "message": "Workflow dispatched"})
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "Workflow dispatch timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to dispatch workflow: {exc}"})


@app.post("/api/sessions/{session_id}/github/actions/runs/{run_id}/rerun")
async def github_actions_rerun_failed(session_id: str, run_id: int) -> JSONResponse:
    """Re-run failed jobs in a workflow run."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "run", "rerun", str(run_id),
            "--repo", repo,
            "--failed",
        ]
        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            if "not found" in error_msg.lower() or "could not find" in error_msg.lower():
                return JSONResponse(status_code=404, content={"error": f"Workflow run {run_id} not found"})
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to re-run workflow"})

        return JSONResponse(content={"success": True})
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "Re-run request timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to re-run workflow: {exc}"})


@app.post("/api/sessions/{session_id}/github/actions/runs/{run_id}/cancel")
async def github_actions_cancel_run(session_id: str, run_id: int) -> JSONResponse:
    """Cancel an in-progress workflow run."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    repo = _get_repo_from_remote(target)
    if not repo:
        return JSONResponse(
            status_code=400,
            content={"error": "Could not determine GitHub repo from git remote"},
        )

    loop = asyncio.get_running_loop()
    try:
        cmd = [
            "run", "cancel", str(run_id),
            "--repo", repo,
        ]
        result = await loop.run_in_executor(None, lambda: _run_gh(cmd, cwd=str(target)))
        if result.returncode != 0:
            error_msg = result.stderr.strip()
            if "not found" in error_msg.lower() or "could not find" in error_msg.lower():
                return JSONResponse(status_code=404, content={"error": f"Workflow run {run_id} not found"})
            return JSONResponse(status_code=400, content={"error": error_msg or "Failed to cancel run"})

        return JSONResponse(content={"success": True})
    except FileNotFoundError as exc:
        return JSONResponse(status_code=400, content={"error": str(exc)})
    except subprocess.TimeoutExpired:
        return JSONResponse(status_code=504, content={"error": "Cancel request timed out"})
    except OSError as exc:
        return JSONResponse(status_code=500, content={"error": f"Failed to cancel run: {exc}"})


# ---------------------------------------------------------------------------
# Documentation generation endpoints
# ---------------------------------------------------------------------------


async def _run_doc_generation(project_dir: str, loki_cli: str) -> None:
    """Run loki docs generate in background after repo import."""
    loop = asyncio.get_running_loop()
    try:
        await loop.run_in_executor(None, lambda: subprocess.run(
            [loki_cli, "docs", "generate"],
            cwd=project_dir,
            capture_output=True,
            timeout=300,
        ))
        logger.info("Auto-generated documentation for %s", project_dir)
    except Exception as e:
        logger.warning("Doc generation failed: %s", e)


@app.post("/api/sessions/{session_id}/docs/generate")
async def docs_generate(session_id: str) -> JSONResponse:
    """Trigger loki docs generate in the session's project directory."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    _cleanup_chat_tasks()
    task = ChatTask()
    _chat_tasks[task.id] = task

    async def run_docs() -> None:
        loki = _find_loki_cli()
        if loki is None:
            task.output_lines = ["loki CLI not found"]
            task.returncode = 1
            task.complete = True
            return
        proc: Optional[subprocess.Popen] = None
        try:
            proc = subprocess.Popen(
                [loki, "docs", "generate"],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                cwd=str(target),
                start_new_session=True,
            )
            task.process = proc
            _track_child_pid(proc.pid)
            loop = asyncio.get_running_loop()

            def _read() -> None:
                assert proc.stdout is not None
                for raw_line in proc.stdout:
                    if task.cancelled:
                        break
                    clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
                    stripped = clean.strip()
                    if not stripped:
                        continue
                    task.output_lines.append(stripped)
                proc.wait()

            await loop.run_in_executor(None, _read)
            task.returncode = proc.returncode or 0
        except Exception as exc:
            task.output_lines.append(f"Doc generation failed: {exc}")
            task.returncode = 1
        finally:
            task.complete = True
            if proc and proc.pid:
                _untrack_child_pid(proc.pid)

    asyncio.create_task(run_docs())
    return JSONResponse(content={"task_id": task.id, "message": "Documentation generation started"})


@app.get("/api/sessions/{session_id}/docs/status")
async def docs_status(session_id: str) -> JSONResponse:
    """Read .loki/docs/docs-manifest.json from the project directory."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    docs_dir = target / ".loki" / "docs"
    manifest_path = docs_dir / "docs-manifest.json"

    if not manifest_path.exists():
        return JSONResponse(content={
            "has_docs": False,
            "coverage_pct": 0,
            "doc_files": [],
            "last_generated": None,
            "commits_behind": 0,
        })

    try:
        manifest = json.loads(manifest_path.read_text())
    except (json.JSONDecodeError, OSError):
        return JSONResponse(content={
            "has_docs": False,
            "coverage_pct": 0,
            "doc_files": [],
            "last_generated": None,
            "commits_behind": 0,
        })

    # List doc files
    doc_files = []
    if docs_dir.exists():
        doc_files = [
            f.name for f in docs_dir.iterdir()
            if f.is_file() and f.suffix in (".md", ".txt", ".html") and f.name != "docs-manifest.json"
        ]

    # Count commits behind (compare HEAD to last_generated commit if available)
    commits_behind = 0
    last_commit = manifest.get("last_commit")
    if last_commit:
        try:
            result = subprocess.run(
                ["git", "rev-list", "--count", f"{last_commit}..HEAD"],
                cwd=str(target),
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode == 0:
                commits_behind = int(result.stdout.strip())
        except (subprocess.TimeoutExpired, ValueError, OSError):
            pass

    return JSONResponse(content={
        "has_docs": True,
        "coverage_pct": manifest.get("coverage_pct", 0),
        "doc_files": doc_files,
        "last_generated": manifest.get("last_generated"),
        "commits_behind": commits_behind,
    })


@app.get("/api/sessions/{session_id}/docs/files")
async def docs_list_files(session_id: str) -> JSONResponse:
    """List all generated doc files in .loki/docs/."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    docs_dir = target / ".loki" / "docs"
    if not docs_dir.exists():
        return JSONResponse(content={"files": []})

    files = []
    for f in sorted(docs_dir.iterdir()):
        if f.is_file() and f.name != "docs-manifest.json":
            stat = f.stat()
            files.append({
                "name": f.name,
                "size": stat.st_size,
                "generated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
            })

    return JSONResponse(content={"files": files})


@app.get("/api/sessions/{session_id}/docs/files/{filename}")
async def docs_get_file(session_id: str, filename: str) -> Response:
    """Return the content of a specific generated doc file."""
    target, err = _validate_session_and_find_dir(session_id)
    if err:
        return err

    # Sanitize filename to prevent directory traversal
    if "/" in filename or "\\" in filename or ".." in filename:
        return JSONResponse(status_code=400, content={"error": "Invalid filename"})

    doc_path = target / ".loki" / "docs" / filename
    if not doc_path.exists() or not doc_path.is_file():
        return JSONResponse(status_code=404, content={"error": "Doc file not found"})

    try:
        content = doc_path.read_text()
    except OSError:
        return JSONResponse(status_code=500, content={"error": "Cannot read doc file"})

    return Response(content=content, media_type="text/markdown")


# ---------------------------------------------------------------------------
# Evidence Receipts.
#
# MUST be declared ABOVE the SPA catch-all below, or these return text/html
# instead of JSON -- the exact v7.6.0 bug documented there, which silently
# swallowed 22 API routes.
#
# The EvidenceReceiptPanel calls these. Without them `loki web` (port 57375)
# rendered "Could not load receipts" for every user while the identical panel
# worked against the dashboard on 57374, because only the dashboard defined
# /api/proofs. Found by driving the built bundle in a real browser -- a unit
# test against a stubbed fetch cannot see a route that does not exist.
#
# Delegates to dashboard.server's readers rather than reimplementing the
# traversal guards and the honesty bucketing. Two implementations of "which
# receipts exist" WILL drift, and a verification surface that disagrees with
# itself is worse than one that is merely missing.
# ---------------------------------------------------------------------------
def _dashboard_proofs_module():
    """Import the dashboard's proof readers, or None if unavailable."""
    try:
        import sys as _sys
        _root = str(SCRIPT_DIR.parent)
        if _root not in _sys.path:
            _sys.path.insert(0, _root)
        from dashboard import server as _dash  # type: ignore
        return _dash
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Cost readers, delegated for the same reason as the proof readers above.
#
# MetricsPage rendered SEVEN hardcoded arrays (cost trend, token split, builds
# per day, radar axes/datasets, timeline phases, KPIs) with zero fetch calls --
# invented numbers presented as measurement. The dashboard already computes the
# real series at /api/cost and /api/cost/timeline, and its implementation is
# explicit that "Cost is never fabricated: when nothing was recorded,
# cost_recorded is False and totals are honestly null rather than a misleading
# $0.00." Delegating keeps that honesty rather than forking it.
# ---------------------------------------------------------------------------
@app.get("/api/cost")
async def webapp_cost() -> Response:
    """Current cost totals. Mirrors the dashboard's /api/cost exactly."""
    dash = _dashboard_proofs_module()
    if dash is None:
        # Honest "unmeasured", never a fabricated zero. A partial install
        # cannot read cost; saying $0.00 would be a lie the UI would render.
        return JSONResponse(content={"cost_recorded": False, "total_usd": None})
    try:
        return JSONResponse(content=await dash.get_cost())
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": str(e)})


@app.get("/api/cost/timeline")
async def webapp_cost_timeline() -> Response:
    """Cost over time. Mirrors the dashboard's /api/cost/timeline exactly."""
    dash = _dashboard_proofs_module()
    if dash is None:
        return JSONResponse(content={
            "current_run": {"iterations": [], "total_usd": None,
                            "cost_recorded": False},
            "runs": [],
            # Unreadable, not zero: the Runs-with-cost tile says "Not recorded".
            "runs_count": None,
        })
    try:
        return JSONResponse(content=await dash.get_cost_timeline())
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": str(e)})


@app.get("/api/proofs")
async def webapp_list_proofs() -> Response:
    """List Evidence Receipts. Mirrors the dashboard's /api/proofs exactly."""
    dash = _dashboard_proofs_module()
    if dash is None:
        # An honest empty list, not a 500: a missing dashboard package means we
        # cannot read receipts, and the panel's own empty state explains that
        # none were found. A 500 would render as a scary error for what is a
        # normal partial install.
        return JSONResponse(content={"proofs": []})
    try:
        return JSONResponse(content=await dash.list_proofs())
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": str(e)})


@app.get("/api/proofs/summary")
async def webapp_proofs_summary() -> Response:
    """Verdict distribution across receipts. Mirrors the dashboard endpoint."""
    dash = _dashboard_proofs_module()
    if dash is None:
        return JSONResponse(content={
            "total_receipts": 0, "verified": 0, "with_gaps": 0,
            "not_verified": 0, "unknown": 0,
        })
    try:
        return JSONResponse(content=await dash.proofs_summary())
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": str(e)})


@app.get("/api/proofs/{run_id}")
async def webapp_get_proof(run_id: str) -> Response:
    """One receipt. The dashboard reader enforces the path-traversal guard."""
    dash = _dashboard_proofs_module()
    if dash is None:
        return JSONResponse(status_code=404, content={"error": "proofs unavailable"})
    try:
        return await dash.get_proof(run_id)
    except Exception as e:
        # HTTPException carries its own status (404 for an unknown run id);
        # anything else is a genuine read failure.
        status = getattr(e, "status_code", 500)
        detail = getattr(e, "detail", str(e))
        return JSONResponse(status_code=status, content={"error": detail})


# ---------------------------------------------------------------------------
# SPA catch-all (MUST be last @app route -- see comment at the old serve_spa
# location higher in this file). Any path that isn't matched by a specific
# @app.get/post/... above falls through here and serves the React bundle.
# v7.6.0 bug fix: previously this was at line 7725 and silently swallowed
# 22 downstream API routes (/api/magic/*, /api/deploy/*, /api/sessions/.../
# github/actions/*, /api/sessions/.../docs/*) which returned text/html
# instead of JSON. Real-user test (Playwright on /lab/magic) surfaced it.
# ---------------------------------------------------------------------------

@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
    """Serve the React SPA and static assets from dist/.

    AN /api/ PATH NEVER REACHES THE SPA. Before this guard, a dead API GET fell
    through here and returned 200 + text/html. The client (client.ts:45-47)
    saw a non-JSON content-type and told the user "API endpoint not available.
    Please restart the server with the latest version." Restarting can never
    fix a client-side path typo, so the one actionable-looking message the user
    got was a lie, and it pointed away from the real cause.

    The comment below records that this same catch-all once swallowed 22 API
    routes in v7.6.0. That fix moved the route; it did not remove the hazard.
    This returns the hazard to a loud, honest 404.

    The Vite build is configured with base: '/lab/' (Phase Merge-3), so the
    bundled HTML references assets at '/lab/assets/...'. Under the canonical
    standalone_app (and dashboard's Merge-4 mount), Starlette's Mount strips
    '/lab' from the scope path before dispatch, so this handler usually sees
    'assets/...' directly and the strip below is a no-op. The strip is
    retained as defense-in-depth for direct-`app` invocations (e.g. tests or
    operators running `uvicorn server:app` instead of `server:standalone_app`).
    """
    if full_path.startswith("api/") or full_path == "api":
        return JSONResponse(
            status_code=404,
            content={"error": f"No such API endpoint: /{full_path}"},
        )

    index = DIST_DIR / "index.html"
    if not index.exists():
        return JSONResponse(
            status_code=503,
            content={"error": "Web app not built. Run: cd web-app && npm run build"},
        )
    # Resolve to the dist directory, tolerating the /lab/ base in standalone mode.
    relative = full_path[4:] if full_path.startswith("lab/") else full_path
    requested = DIST_DIR / relative
    if relative and requested.is_file() and str(requested.resolve()).startswith(str(DIST_DIR.resolve())):
        import mimetypes
        content_type = mimetypes.guess_type(str(requested))[0] or "application/octet-stream"
        return FileResponse(str(requested), media_type=content_type)
    # SPA fallback: return index.html for all non-file routes
    return FileResponse(str(index))


# ---------------------------------------------------------------------------
# Standalone wrapper (Phase Merge-3): mounts the FastAPI `app` under '/lab/'
# so the rebased Vite bundle (with base: '/lab/') routes correctly through
# the existing API and WS handlers when run via `loki web`. The same `app`
# is imported and mounted at '/lab/' by `dashboard/server.py` in Merge-4 --
# this wrapper exists ONLY for the standalone path so both modes share one
# rebased bundle and one canonical handler set.
# ---------------------------------------------------------------------------

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.responses import RedirectResponse as _RedirectResponse


async def _root_to_lab(request):  # noqa: ANN001
    """Redirect legacy '/' visits to '/lab/' so React Router basename matches."""
    return _RedirectResponse(url="/lab/", status_code=307)


standalone_app = Starlette(
    routes=[
        Route("/", _root_to_lab),
        Mount("/lab", app=app),
    ],
)


if __name__ == "__main__":
    main()
