"""Unified FastAPI app — PSB2: consolidates feed, roster, persona, settings on port 4545.

Services are created by the CLI and passed via create_app(services).
They are used without calling .start() — this app handles all HTTP.

Tauri sidecar: use run_server_blocking() for graceful SIGTERM handling.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import socket
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any


def find_available_port(host: str, start_port: int, max_attempts: int = 10) -> int:
    """Find an available port starting from start_port. Returns the port number."""
    for i in range(max_attempts):
        port = start_port + i
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                s.bind((host, port))
                return port
        except OSError:
            continue
    return start_port  # fallback: caller will get bind error


from fastapi import FastAPI, File, Form, Request, UploadFile  # noqa: E402
from fastapi.responses import HTMLResponse, JSONResponse  # noqa: E402

logger = logging.getLogger(__name__)


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    """Startup/shutdown for Tauri sidecar. Graceful cleanup on SIGTERM."""
    yield
    # Shutdown: flush queues, close connections
    try:
        services = getattr(app.state, "services", {}) or {}
        task_queue = services.get("task_queue")
        if task_queue and hasattr(task_queue, "close"):
            task_queue.close()
            logger.debug("TaskQueue closed")
    except Exception as e:
        logger.debug("Shutdown cleanup: %s", e)


async def _parse_json_body(request: Request) -> dict:
    """Parse JSON body; return {} if empty or invalid."""
    try:
        body = await request.body()
        if not body:
            return {}
        return json.loads(body.decode("utf-8"))
    except Exception:
        return {}


def _get_version() -> str:
    """Read version from package metadata. Tauri expects format like 1.0.0-alpha."""
    try:
        import importlib.metadata

        return importlib.metadata.version("vastian-network")
    except Exception:
        return "0.1.0"


async def _parse_command(message: str, orchestrator: Any, services: dict) -> dict | None:
    """Parse /slash commands. Returns response dict or None if not a command."""
    parts = message.strip().split(None, 1)
    cmd = parts[0].lower() if parts else ""
    arg = parts[1].strip() if len(parts) > 1 else ""

    if cmd == "/help":
        from vastian.tasks.feed import CLI_COMMANDS

        lines = [f"**{c['cmd']}** — {c['desc']}" for c in CLI_COMMANDS]
        return {
            "ok": True,
            "response": "Available commands:\n\n" + "\n".join(lines),
            "agent_id": "system",
            "command": "help",
        }

    if cmd == "/agent":
        if not arg:
            return {
                "ok": True,
                "response": "Usage: /agent <agent_id>",
                "agent_id": "system",
                "command": "error",
            }
        agent = orchestrator.registry.get_agent(arg.lower()) if orchestrator else None
        if agent:
            return {
                "ok": True,
                "response": f"Switched to **{agent.agent_id}** ({getattr(agent, 'title', '')})",
                "agent_id": arg.lower(),
                "command": "switch_agent",
            }
        return {
            "ok": True,
            "response": f"Unknown agent: {arg}",
            "agent_id": "system",
            "command": "error",
        }

    if cmd == "/bg":
        tq = services.get("task_queue") or (orchestrator.task_queue if orchestrator else None)
        if tq:
            running = len(tq.get_running())
            pending = len(tq.get_pending())
            return {
                "ok": True,
                "response": f"Background tasks: **{running}** running, **{pending}** pending",
                "agent_id": "system",
                "command": "status",
            }
        return {
            "ok": True,
            "response": "Task queue not available.",
            "agent_id": "system",
            "command": "status",
        }

    if cmd == "/onboard":
        from pathlib import Path

        from vastian.storage.data_root import get_saved_prompts_dir

        project_root = services.get("project_root") or Path.cwd()
        intro_path = get_saved_prompts_dir(project_root) / "intro.txt"
        if not intro_path.exists():
            return {
                "ok": True,
                "response": "Onboarding requires saved_prompts/intro.txt. Write about yourself first.",
                "agent_id": "system",
                "command": "error",
            }
        try:
            intro_content = intro_path.read_text(encoding="utf-8").strip()
        except OSError:
            return {
                "ok": True,
                "response": "Could not read intro.txt.",
                "agent_id": "system",
                "command": "error",
            }
        if len(intro_content) < 50:
            return {
                "ok": True,
                "response": "intro.txt is too short. Write at least a paragraph (50+ chars).",
                "agent_id": "system",
                "command": "error",
            }
        tq = services.get("task_queue") or (orchestrator.task_queue if orchestrator else None)
        if not tq or not orchestrator:
            return {
                "ok": True,
                "response": "Task queue not available.",
                "agent_id": "system",
                "command": "error",
            }
        task = tq.enqueue(
            task_type="onboarding",
            agent_id="charles",
            description="Onboarding: populate all agent docs from intro.txt",
            payload={"intro_path": str(intro_path)},
        )
        asyncio.create_task(orchestrator._execute_background_task(task.task_id))
        return {
            "ok": True,
            "response": f"Onboarding started (task #{task.task_id}). Charles is delegating to your team.",
            "agent_id": "system",
            "command": "onboard",
        }

    if cmd == "/tasks":
        tq = services.get("task_queue") or (orchestrator.task_queue if orchestrator else None)
        if tq:
            try:
                kanban = tq.get_kanban_tasks()
                by_status: dict[str, int] = {}
                for t in kanban:
                    s = t.get("status", "unknown")
                    by_status[s] = by_status.get(s, 0) + 1
                summary = ", ".join(f"**{s}**: {c}" for s, c in sorted(by_status.items()))
                return {
                    "ok": True,
                    "response": f"Kanban summary: {summary or 'empty'}",
                    "agent_id": "system",
                    "command": "status",
                }
            except Exception:
                pass
        return {
            "ok": True,
            "response": "No kanban data available.",
            "agent_id": "system",
            "command": "status",
        }

    if cmd == "/checkin":
        if orchestrator:
            try:
                response = await orchestrator.handle_user_message_direct(
                    content="Give me a proactive check-in with any pending items, suggestions, or things I should know about.",
                    agent_id="charles",
                )
                return {
                    "ok": True,
                    "response": response or "No check-in items.",
                    "agent_id": "charles",
                    "command": "checkin",
                }
            except Exception as e:
                return {
                    "ok": True,
                    "response": f"Check-in error: {e}",
                    "agent_id": "system",
                    "command": "error",
                }
        return {
            "ok": True,
            "response": "Orchestrator not available.",
            "agent_id": "system",
            "command": "error",
        }

    if cmd == "/council":
        if not arg:
            return {
                "ok": True,
                "response": "Usage: /council <topic>",
                "agent_id": "system",
                "command": "error",
            }
        if orchestrator and orchestrator.council:
            try:
                result = await orchestrator.council.convene(topic=arg, council_type="ad_hoc")
                synthesis = result.get("synthesis", "Council completed with no synthesis.")
                return {
                    "ok": True,
                    "response": synthesis,
                    "agent_id": "council",
                    "command": "council",
                }
            except Exception as e:
                return {
                    "ok": True,
                    "response": f"Council error: {e}",
                    "agent_id": "system",
                    "command": "error",
                }
        return {
            "ok": True,
            "response": "Council not available.",
            "agent_id": "system",
            "command": "error",
        }

    if cmd == "/admin" and arg.startswith("tier"):
        tier_parts = arg.split(None, 1)
        tier_val = tier_parts[1].strip() if len(tier_parts) > 1 else ""
        if tier_val == "reset":
            from vastian.storage.data_root import get_data_root

            state_file = (
                get_data_root(services.get("project_root") or Path.cwd())
                / "state"
                / "user_tier.json"
            )
            if state_file.exists():
                data = json.loads(state_file.read_text(encoding="utf-8"))
                data.pop("override", None)
                state_file.write_text(json.dumps(data), encoding="utf-8")
            return {
                "ok": True,
                "response": "Admin tier override cleared.",
                "agent_id": "system",
                "command": "admin",
            }
        try:
            tier_num = int(tier_val)
            from vastian.storage.data_root import get_data_root

            state_dir = get_data_root(services.get("project_root") or Path.cwd()) / "state"
            state_dir.mkdir(parents=True, exist_ok=True)
            state_file = state_dir / "user_tier.json"
            data = {}
            if state_file.exists():
                data = json.loads(state_file.read_text(encoding="utf-8"))
            data["override"] = tier_num
            state_file.write_text(json.dumps(data), encoding="utf-8")
            return {
                "ok": True,
                "response": f"Admin tier override set to **{tier_num}**.",
                "agent_id": "system",
                "command": "admin",
            }
        except ValueError:
            return {
                "ok": True,
                "response": "Usage: /admin tier <0-3> or /admin tier reset",
                "agent_id": "system",
                "command": "error",
            }

    return None  # Not a recognized command — pass to LLM


def create_app(services: dict[str, Any] | None = None) -> FastAPI:
    """Create FastAPI app with all routes. services: {feed, roster, chart, settings}."""
    app = FastAPI(
        title="Vastian Network", docs_url="/api/docs", redoc_url="/api/redoc", lifespan=_lifespan
    )
    app.state.services = services or {}

    # ── Health (always available, for Tauri sidecar) ─────────────────────
    @app.get("/api/health")
    def _api_health() -> dict:
        return {"status": "ready", "version": _get_version()}

    # ── Static assets (logo, favicon) ─────────────────────────────────────
    from fastapi.responses import FileResponse as _FileResponse

    @app.get("/static/{filename:path}")
    def _static_file(filename: str):  # noqa: ANN202
        """Serve static assets from neptune/public/ or bundled resources."""
        import sys

        candidates: list[Path] = []
        if getattr(sys, "frozen", False):
            # PyInstaller bundle: look next to the executable
            candidates.append(Path(sys.executable).parent / "public" / filename)
            candidates.append(Path(getattr(sys, "_MEIPASS", "")) / "public" / filename)
        # Dev mode: look in neptune/public/ relative to project root
        project_root = services.get("project_root") if services else None
        if project_root:
            candidates.append(Path(project_root) / "neptune" / "public" / filename)
        candidates.append(Path(__file__).resolve().parents[3] / "neptune" / "public" / filename)
        for path in candidates:
            if path.is_file():
                return _FileResponse(path)
        return JSONResponse({"error": "Not found"}, status_code=404)

    # ── Ollama status (for setup wizard) ──────────────────────────────────
    @app.get("/api/ollama/status")
    def _api_ollama_status() -> dict:
        """Check if Ollama is installed, running, and which models are available."""
        import httpx

        ollama_url = "http://localhost:11434"
        result: dict[str, object] = {
            "installed": False,
            "running": False,
            "models": [],
            "recommended": None,
        }
        try:
            resp = httpx.get(f"{ollama_url}/api/tags", timeout=3.0)
            if resp.status_code == 200:
                result["installed"] = True
                result["running"] = True
                data = resp.json()
                models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
                # Strip :latest suffix for cleaner display
                clean = [m.replace(":latest", "") for m in models]
                result["models"] = clean
                # Recommend best model for Vastian (tool-calling capable)
                preferred = ["llama3.1", "qwen2.5", "llama3", "mistral", "gemma2"]
                for pref in preferred:
                    if any(m.startswith(pref) for m in clean):
                        result["recommended"] = next(m for m in clean if m.startswith(pref))
                        break
                if not result["recommended"] and clean:
                    result["recommended"] = clean[0]
        except httpx.ConnectError:
            # Ollama not running (or not installed)
            pass
        except Exception:
            pass
        return result

    # ── Worker lock status ───────────────────────────────────────────────
    @app.get("/api/worker/lock")
    def _api_worker_lock() -> dict:
        """Check worker lock status."""
        try:
            from vastian.cli import _read_worker_lock

            project_root = app.state.services.get("project_root")
            if project_root:
                from vastian.config import get_settings

                state_dir = get_settings().state_dir
                lock = _read_worker_lock(state_dir)
                return {"locked": lock is not None, "lock": lock}
        except Exception:
            pass
        return {"locked": False, "lock": None}

    # ── Usage tier calculation ────────────────────────────────────────
    @app.get("/api/user/tier")
    def _api_user_tier() -> dict:
        """Compute the user's current usage tier (0-3)."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.storage.data_root import get_data_root

            data_root = get_data_root(project_root)
            state_file = data_root / "state" / "user_tier.json"
            # Check admin override
            if state_file.exists():
                tier_data = json.loads(state_file.read_text(encoding="utf-8"))
                if "override" in tier_data:
                    ov = tier_data["override"]
                    return {
                        "tier": ov,
                        "tier_name": ["New", "Onboarded", "Active", "Power"][min(ov, 3)],
                        "override": True,
                    }
            # Count user data to determine tier
            doc_count = 0
            docs_dir = data_root / "docs"
            if docs_dir.exists():
                for d in docs_dir.iterdir():
                    if d.is_dir():
                        doc_count += sum(1 for f in d.glob("*.md") if f.stat().st_size > 100)
            kanban_count = 0
            orch = app.state.services.get("orchestrator")
            tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
            if tq:
                with suppress(Exception):
                    kanban_count = len(tq.get_kanban_tasks())
            # Tier logic
            tier = 0
            if doc_count >= 1:
                tier = 1
            if kanban_count >= 5 or doc_count >= 5:
                tier = 2
            if doc_count >= 10:
                tier = 3
            names = ["New", "Onboarded", "Active", "Power"]
            hints = [
                "Chat with Charles to begin onboarding.",
                "Create 4 more documents to unlock advanced features.",
                "Keep building — 10 documents unlocks full power.",
                "All features unlocked!",
            ]
            # Detect deployment mode (local vs cloud)
            deploy_mode = "local"
            try:
                from vastian.config import get_settings as _gs

                _settings = _gs()
                if getattr(_settings, "deployment_mode", None):
                    deploy_mode = _settings.deployment_mode
                elif os.environ.get("VASTIAN_DEPLOY_MODE"):
                    deploy_mode = os.environ["VASTIAN_DEPLOY_MODE"]
                elif os.environ.get("ZO_WORKSPACE"):
                    deploy_mode = "cloud"
            except Exception:
                pass
            # Tier 3-4 requires cloud deployment
            if tier >= 3 and deploy_mode == "local":
                tier = 2  # cap at tier 2 for local users
            return {
                "tier": tier,
                "tier_name": names[min(tier, 3)],
                "next_tier_hint": hints[min(tier, 3)],
                "doc_count": doc_count,
                "kanban_count": kanban_count,
                "deployment_mode": deploy_mode,
                "tier_cap": 2 if deploy_mode == "local" else 4,
            }
        except Exception:
            return {"tier": 0, "tier_name": "New", "next_tier_hint": "Chat with Charles to begin."}

    # ── Interview progress tracking ────────────────────────────────────
    @app.get("/api/interview/progress")
    def _api_interview_progress() -> dict:
        """Get interview completion progress for all agents."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.storage.data_root import get_data_root

            data_root = get_data_root(project_root)
            progress_file = data_root / "state" / "interview-progress.json"
            if progress_file.exists():
                from vastian.storage.file_store import FileStore

                store = FileStore(
                    progress_file, id_field="agent_id", store_name="interview-progress"
                )
                records = store.load_all()
                # Calculate overall completion
                total_qs = sum(r.get("total", 0) for r in records)
                completed_qs = sum(len(r.get("completed", [])) for r in records)
                pct = int(completed_qs * 100 / total_qs) if total_qs > 0 else 0
                return {
                    "ok": True,
                    "agents": records,
                    "total_questions": total_qs,
                    "completed_questions": completed_qs,
                    "completion_pct": pct,
                }
            return {
                "ok": True,
                "agents": [],
                "total_questions": 0,
                "completed_questions": 0,
                "completion_pct": 0,
            }
        except Exception as e:
            return {"ok": False, "error": str(e)}

    @app.post("/api/interview/progress")
    async def _api_interview_progress_update(request: Request) -> dict:
        """Update interview progress for a specific agent."""
        body = await _parse_json_body(request)
        agent_id = (body.get("agent_id") or "").strip()
        question_id = (body.get("question_id") or "").strip()
        if not agent_id or not question_id:
            return {"ok": False, "error": "agent_id and question_id are required"}

        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.storage.data_root import get_data_root

            data_root = get_data_root(project_root)
            progress_file = data_root / "state" / "interview-progress.json"
            from vastian.storage.file_store import FileStore

            store = FileStore(progress_file, id_field="agent_id", store_name="interview-progress")
            record = store.get(agent_id)
            if record is None:
                # Count questions from agent config
                orch = app.state.services.get("orchestrator")
                total = 0
                if orch:
                    agent = orch.registry.get_agent(agent_id)
                    if agent and hasattr(agent.config, "interview_questions"):
                        total = len(agent.config.interview_questions)
                record = {"agent_id": agent_id, "completed": [], "current_index": 0, "total": total}
            completed = record.get("completed", [])
            if question_id not in completed:
                completed.append(question_id)
            record["completed"] = completed
            record["current_index"] = len(completed)
            store.upsert(record)
            return {"ok": True, "record": record}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ── Copilot chat ─────────────────────────────────────────────────────
    orchestrator = app.state.services.get("orchestrator")

    @app.get("/api/chat/commands")
    def _api_chat_commands() -> dict:
        from vastian.tasks.feed import AGENT_META, CLI_COMMANDS

        commands = [
            {"cmd": c["cmd"], "desc": c["desc"], "topic": c.get("topic", "")} for c in CLI_COMMANDS
        ]
        agents = [
            {
                "id": a["id"],
                "name": a["name"],
                "title": a.get("title", ""),
                "tier": a.get("tier", "background"),
            }
            for a in AGENT_META
        ]
        return {"commands": commands, "agents": agents}

    @app.get("/api/chat/history")
    async def _api_chat_history(agent_id: str = "charles", limit: int = 50) -> dict:
        """Load recent chat messages for a specific agent from SQLite."""
        if not orchestrator or not orchestrator.knowledge:
            return {"messages": []}
        try:
            messages = await orchestrator.knowledge.structured.load_conversation(
                agent_id, limit=limit
            )
            return {"messages": messages, "agent_id": agent_id}
        except Exception as e:
            logging.getLogger(__name__).exception("Failed to load chat history for %s", agent_id)
            return {"messages": [], "error": str(e)}

    @app.get("/api/chat/threads")
    async def _api_chat_threads() -> dict:
        """List agents with recent conversation activity for the thread list."""
        if not orchestrator or not orchestrator.knowledge:
            return {"threads": []}
        try:
            threads = await orchestrator.knowledge.structured.list_conversation_agents()
            # Enrich with agent name/title from AGENT_META
            from vastian.tasks.feed import AGENT_META

            meta_map = {a["id"]: a for a in AGENT_META}
            for t in threads:
                meta = meta_map.get(t["agent_id"], {})
                t["name"] = meta.get("name", t["agent_id"].title())
                t["title"] = meta.get("title", "")
            return {"threads": threads}
        except Exception as e:
            logging.getLogger(__name__).exception("Failed to list chat threads")
            return {"threads": [], "error": str(e)}

    # ── Councils API (Wave 3) ─────────────────────────────────────────────
    @app.get("/api/councils/mentors")
    def _api_councils_mentors() -> dict:
        """Return mentor roster from config/mentors or default wisdom figures."""
        mentors: list[dict] = []
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_config_dir

            project_root = app.state.services.get("project_root") or Path.cwd()
            mdir = get_config_dir(project_root) / "mentors"
            if mdir.exists():
                import yaml

                for f in sorted(mdir.glob("*.yaml")):
                    data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
                    if data.get("name"):
                        mentors.append(
                            {
                                "id": f.stem,
                                "name": data["name"],
                                "archetype": data.get("archetype", ""),
                            }
                        )
        except Exception:
            pass
        # No hardcoded fallbacks — mentors appear after Victor/Sage suggest them
        return {"mentors": mentors}

    @app.get("/api/councils/sessions")
    async def _api_councils_sessions() -> dict:
        """Return council session history."""
        knowledge = None
        orch = app.state.services.get("orchestrator")
        if orch and orch.knowledge:
            knowledge = orch.knowledge.structured
        if not knowledge:
            return {"sessions": []}
        try:
            sessions = await knowledge.get_council_sessions(limit=20)
            return {"sessions": [dict(s) for s in sessions]}
        except Exception:
            return {"sessions": []}

    @app.post("/api/councils/start")
    async def _api_councils_start(request: Request) -> dict:
        """Start a council. Runs synchronously — may take 30-60s."""
        body = await _parse_json_body(request)
        topic = (body.get("topic") or "").strip()
        council_type = (body.get("council_type") or "ad_hoc").lower()
        if not topic:
            return {"ok": False, "error": "topic is required"}
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.council:
            return {
                "ok": False,
                "error": "Council requires orchestrator. Start with 'vastian chat' or 'vastian serve'.",
            }
        try:
            result = await orch.council.convene(topic=topic, council_type=council_type)
            return {
                "ok": True,
                "session_id": result.get("session_id"),
                "synthesis": result.get("synthesis", ""),
                "action_items": result.get("action_items", []),
            }
        except Exception as e:
            logging.getLogger(__name__).exception("Council start error")
            return {"ok": False, "error": str(e)}

    @app.post("/api/chat")
    async def _api_chat(request: Request) -> dict:
        """Process a chat message. Returns agent response. Requires orchestrator in services."""
        body = await _parse_json_body(request)
        message = (body.get("message") or "").strip()
        agent_id = (body.get("agent_id") or "charles").lower()
        chat_mode = (body.get("mode") or "freeform").lower()
        if not message:
            return {"ok": False, "error": "message is required"}
        if not orchestrator:
            return {
                "ok": False,
                "error": "Chat requires orchestrator. Start with 'vastian chat' or 'vastian serve'.",
            }

        # Command parsing — handle /slash commands before LLM
        if message.startswith("/"):
            cmd_result = await _parse_command(message, orchestrator, app.state.services)
            if cmd_result is not None:
                return cmd_result

        # Store chat mode on orchestrator so system prompt injection can use it
        orchestrator._current_chat_mode = chat_mode
        # Pass active document context for interview mode (agent generates questions from it)
        orchestrator._interview_active_doc = (body.get("active_doc") or "").strip()

        try:
            routed_to = None
            # When Charles is the default, use IntentRouter for smart routing
            if agent_id == "charles" and orchestrator.router:
                routed_to = await orchestrator.router.route(message)
                if routed_to and routed_to != "charles":
                    response = await orchestrator.handle_user_message_direct(
                        content=message,
                        agent_id=routed_to,
                    )
                else:
                    routed_to = None  # Charles handled it directly
                    response = await orchestrator.handle_user_message_direct(
                        content=message,
                        agent_id="charles",
                    )
            else:
                response = await orchestrator.handle_user_message_direct(
                    content=message,
                    agent_id=agent_id,
                )
            result: dict = {"ok": True, "response": response or "", "agent_id": agent_id}
            if routed_to:
                result["routed_to"] = routed_to

            # Attach any structured questions the agent queued during this turn
            pending_questions = orchestrator.pop_pending_structured_questions()
            if pending_questions:
                result["structured_questions"] = pending_questions

            # Attach tool execution log for the chat turn
            tool_log = orchestrator.pop_tool_exec_log()
            if tool_log:
                result["tool_calls"] = tool_log

            return result
        except Exception as e:
            logging.getLogger(__name__).exception("Chat error")
            return {"ok": False, "error": str(e)}

    # ── Tool allowlist (Cursor-style remember-choice) ──────────────
    @app.get("/api/tool-allowlist")
    def _api_get_tool_allowlist() -> dict:
        """Return the user's tool allowlist."""
        orch = app.state.services.get("orchestrator")
        if orch:
            return {"allowlist": getattr(orch, "_tool_allowlist", {})}
        return {"allowlist": {}}

    @app.post("/api/tool-allowlist")
    async def _api_set_tool_allowlist(request: Request) -> dict:
        """Update a tool in the allowlist."""
        body = await _parse_json_body(request)
        tool_name = body.get("tool", "")
        allowed = body.get("allowed", True)
        orch = app.state.services.get("orchestrator")
        if orch and tool_name:
            if not hasattr(orch, "_tool_allowlist"):
                orch._tool_allowlist = {}
            orch._tool_allowlist[tool_name] = allowed
            # Persist to settings
            try:
                from vastian.config_store import set_config_value

                set_config_value("tool_allowlist", orch._tool_allowlist)
            except Exception:
                pass
        return {"ok": True, "tool": tool_name, "allowed": allowed}

    # ── Chat abort (cancel current agent operation) ─────────────────
    _chat_abort_flag: dict[str, bool] = {"abort": False}

    @app.post("/api/chat/abort")
    def _api_chat_abort() -> dict:
        """Signal the current chat operation to abort."""
        _chat_abort_flag["abort"] = True
        orch = app.state.services.get("orchestrator")
        if orch:
            orch._abort_requested = True
        return {"ok": True, "message": "Abort signal sent"}

    @app.get("/api/chat/abort/status")
    def _api_chat_abort_status() -> dict:
        return {"abort": _chat_abort_flag.get("abort", False)}

    # ── Check-in items per agent ──────────────────────────────────
    @app.get("/api/checkins/{agent_id}")
    def _api_agent_checkins(agent_id: str) -> dict:
        """Return check-in items for a specific agent."""
        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
        items: list[dict] = []

        # 1. Pending suggestions from this agent
        if tq:
            with suppress(Exception):
                suggestions = tq.get_suggestions(agent_id=agent_id, status="pending")
                for s in suggestions[:5]:
                    items.append(
                        {
                            "id": s.get("id", ""),
                            "type": "suggestion",
                            "title": s.get("title", s.get("type", "Suggestion")),
                            "description": s.get("description", ""),
                            "priority": s.get("priority", "medium"),
                            "agent_id": agent_id,
                            "action": "review",
                        }
                    )

        # 2. Pending kanban tasks suggested by this agent
        if tq:
            with suppress(Exception):
                tasks = tq.get_kanban_tasks(status="suggested")
                for t in tasks:
                    if t.get("suggested_by") == agent_id:
                        items.append(
                            {
                                "id": t.get("kanban_id", ""),
                                "type": "task",
                                "title": t.get("title", "Task"),
                                "description": t.get("description", ""),
                                "priority": t.get("priority", "medium"),
                                "agent_id": agent_id,
                                "action": "approve",
                            }
                        )

        # 3. Pending document edits from this agent
        if orch:
            for edit in getattr(orch, "_pending_doc_edits", []):
                if edit.get("agent_id") == agent_id:
                    items.append(
                        {
                            "id": edit.get("id", ""),
                            "type": "doc_edit",
                            "title": f"Edit: {edit.get('doc_path', '').split('/')[-1]}",
                            "description": "Document change needs your review",
                            "priority": "high",
                            "agent_id": agent_id,
                            "action": "review_edit",
                        }
                    )

        # 4. Agent-specific interview questions remaining
        agent_obj = orch.registry.get_agent(agent_id) if orch else None
        if agent_obj and hasattr(agent_obj, "config"):
            qs = getattr(agent_obj.config, "interview_questions", [])
            if qs:
                items.append(
                    {
                        "id": f"interview-{agent_id}",
                        "type": "interview",
                        "title": "Continue getting to know you",
                        "description": f"{len(qs)} questions available",
                        "priority": "low",
                        "agent_id": agent_id,
                        "action": "interview",
                    }
                )

        return {"agent_id": agent_id, "items": items, "count": len(items)}

    # ── Activity log (recent agent actions) ──────────────────────
    @app.get("/api/activity-log")
    def _api_activity_log(request: Request) -> dict:
        """Return recent agent activity for the activity panel.

        Optional query params:
        - ``since`` (epoch ms): filter by timestamp; delegation_complete for toasts
        - ``type``: delegation | council | notification — filter by activity type
        """
        since_ms = request.query_params.get("since")
        type_filter = request.query_params.get("type", "").strip().lower()
        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
        activities: list[dict] = []
        items: list[dict] = []  # for toast polling

        # Recent delegation reports
        if tq:
            with suppress(Exception):
                reports = tq.get_recent_reports(limit=20)
                for r in reports:
                    ts = r.get("completed_at", "")
                    activities.append(
                        {
                            "type": "delegation",
                            "agent_id": r.get("agent_id", ""),
                            "agent_name": r.get("agent_name", r.get("agent_id", "?")),
                            "title": f"{r.get('agent_id', '?')} completed a task",
                            "preview": (r.get("response", "") or "")[:120],
                            "summary": (r.get("response", "") or "")[:80],
                            "timestamp": ts,
                        }
                    )
                    # For toast polling — only recent completions
                    if since_ms:
                        items.append(
                            {
                                "type": "delegation_complete",
                                "agent_id": r.get("agent_id", ""),
                                "agent_name": r.get("agent_name", r.get("agent_id", "?")),
                                "summary": (r.get("response", "") or "")[:80],
                            }
                        )

        # Recent notifications
        if tq:
            with suppress(Exception):
                notifs = tq.get_notifications(limit=10)
                for n in notifs:
                    activities.append(
                        {
                            "type": "notification",
                            "agent_id": n.get("agent_id", "system"),
                            "title": n.get("title", "Notification"),
                            "preview": n.get("body", "")[:120],
                            "timestamp": n.get("created_at", ""),
                        }
                    )

        # Council sessions (from structured knowledge)
        if orch and orch.knowledge and orch.knowledge.structured:
            with suppress(Exception):
                sessions = (
                    asyncio.get_event_loop().run_until_complete(
                        orch.knowledge.structured.get_council_sessions(limit=10)
                    )
                    or []
                )
                for s in sessions:
                    activities.append(
                        {
                            "type": "council",
                            "agent_id": "council",
                            "agent_name": "Council",
                            "title": f"Council: {(s.get('topic') or 'Session')[:50]}",
                            "preview": (s.get("synthesis") or s.get("summary") or "")[:120],
                            "timestamp": s.get("created_at", ""),
                        }
                    )

        # Sort by timestamp descending
        activities.sort(key=lambda a: a.get("timestamp", ""), reverse=True)
        filtered = activities[:30]
        if type_filter:
            filtered = [a for a in filtered if a.get("type") == type_filter]
        result: dict = {"activities": filtered}
        if since_ms:
            result["items"] = items
        return result

    # ── Archive system ────────────────────────────────────────────
    @app.post("/api/archive")
    async def _api_archive_item(request: Request) -> dict:
        """Archive a data item (task, suggestion, notification)."""
        body = await _parse_json_body(request)
        item_type = body.get("type", "")  # task, suggestion, notification, conversation
        item_id = body.get("id", "")
        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)

        if not tq or not item_id:
            return {"ok": False, "error": "Missing item or task queue"}

        # Store archive record
        from vastian.storage.file_store import FileStore

        archive_store = FileStore(
            tq._state_dir / "archive.json"
            if hasattr(tq, "_state_dir")
            else Path("state/archive.json"),
            id_field="id",
        )
        import time as _time

        archive_store.upsert(
            {
                "id": item_id,
                "type": item_type,
                "archived_at": _time.time(),
                "expires_at": _time.time() + (60 * 86400),  # 60-day retention
                "auto_delete": body.get("auto_delete", True),
            }
        )
        return {"ok": True, "id": item_id, "expires_in_days": 60}

    @app.get("/api/archive")
    def _api_get_archive() -> dict:
        """List archived items, optionally filter by type."""
        import time as _time

        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
        state_dir = tq._state_dir if tq and hasattr(tq, "_state_dir") else Path("state")

        from vastian.storage.file_store import FileStore

        archive_store = FileStore(state_dir / "archive.json", id_field="id")
        all_items = archive_store.list()
        now = _time.time()

        # Auto-delete expired items
        active = []
        for item in all_items:
            if item.get("auto_delete") and item.get("expires_at", 0) < now:
                archive_store.delete(item["id"])
            else:
                item["days_remaining"] = max(0, int((item.get("expires_at", now) - now) / 86400))
                active.append(item)

        return {"items": active, "count": len(active)}

    @app.delete("/api/archive/{item_id}")
    def _api_delete_archived(item_id: str) -> dict:
        """Permanently delete an archived item."""
        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
        state_dir = tq._state_dir if tq and hasattr(tq, "_state_dir") else Path("state")

        from vastian.storage.file_store import FileStore

        archive_store = FileStore(state_dir / "archive.json", id_field="id")
        archive_store.delete(item_id)
        return {"ok": True}

    # ── Worker control (local + cloud) ────────────────────────────
    @app.get("/api/worker/status")
    def _api_worker_status() -> dict:
        """Return local worker status."""
        orch = app.state.services.get("orchestrator")
        running = bool(orch and getattr(orch, "_running", False))
        return {
            "running": running,
            "mode": "cloud" if os.environ.get("VASTIAN_DEPLOY_MODE") == "cloud" else "local",
            "loops_active": list(getattr(orch, "_agent_tasks", {}).keys()) if orch else [],
        }

    @app.post("/api/worker/toggle")
    def _api_worker_toggle() -> dict:
        """Toggle background worker loops on/off."""
        orch = app.state.services.get("orchestrator")
        if not orch:
            return {"ok": False, "error": "No orchestrator"}
        if getattr(orch, "_running", False):
            orch._running = False
            # Cancel background tasks
            for _name, task in list(getattr(orch, "_agent_tasks", {}).items()):
                with suppress(Exception):
                    task.cancel()
            return {"ok": True, "running": False, "message": "Worker stopped"}
        else:
            orch._running = True
            return {"ok": True, "running": True, "message": "Worker started"}

    # ── File Upload (inbox + general files) ────────────────────────
    @app.post("/api/upload")
    async def _api_upload(
        files: list[UploadFile] = File(...),  # noqa: B008
        target: str = Form("inbox"),
    ) -> dict:
        """Upload files to inbox or general storage."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        if target == "inbox":
            dest_dir = project_root / "saved_prompts" / "inbox"
        else:
            dest_dir = project_root / "uploads"
        dest_dir.mkdir(parents=True, exist_ok=True)

        saved = 0
        for f in files:
            if not f.filename:
                continue
            safe_name = f.filename.replace("..", "").replace("/", "_").replace("\\", "_")
            dest_path = dest_dir / safe_name
            content = await f.read()
            dest_path.write_bytes(content)
            saved += 1
        return {"ok": True, "count": saved, "target": target}

    # ── Suggestion frequency settings ────────────────────────────
    @app.get("/api/suggestion-frequency")
    def _api_get_suggestion_freq() -> dict:
        """Return current suggestion frequency."""
        from vastian.config_store import config_store

        freq = config_store().get("suggestion_frequency", "medium")
        return {"frequency": freq}

    @app.post("/api/suggestion-frequency")
    async def _api_set_suggestion_freq(request: Request) -> dict:
        """Update suggestion frequency (high/medium/low)."""
        from vastian.config_store import config_store

        body = await request.json()
        freq = body.get("suggestion_frequency", "medium")
        if freq not in ("high", "medium", "low"):
            return {"ok": False, "error": "Invalid frequency"}
        config_store().set("suggestion_frequency", freq)
        return {"ok": True, "frequency": freq}

    # ── Feature gates (progressive unlock) ─────────────────────────
    @app.get("/api/feature-gates")
    def _api_feature_gates() -> dict:
        """Return which sidebar features are unlocked based on user data and tier."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            tier_data = _api_user_tier()
            tier = tier_data.get("tier", 0)
            is_override = tier_data.get("override", False)
            # Tier-based unlock (admin override unlocks everything)
            gates = {
                "dashboard": True,  # always
                "boards": True,  # always
                "settings": True,  # always
                "sessions": tier >= 1 or is_override,
                "personas": tier >= 1 or is_override,
                "scenarios": tier >= 2 or is_override,  # Victor serves contextually
                "plans": tier >= 2 or is_override,
                "metrics": tier >= 1 or is_override,
                "meetings": tier >= 1 or is_override,
                "councils": tier >= 1 or is_override,
                "backup": tier >= 2 or is_override,
            }
            # Also check roadmap unlocks if available
            try:
                from vastian.storage.data_root import get_data_root

                roadmap_file = get_data_root(project_root) / "state" / "roadmap.json"
                if roadmap_file.exists():
                    roadmap = json.loads(roadmap_file.read_text(encoding="utf-8"))
                    for stage in roadmap.get("completed", []):
                        for feature in stage.get("unlocks", []) if isinstance(stage, dict) else []:
                            if feature in gates:
                                gates[feature] = True
            except Exception:
                pass
            return {"gates": gates, "tier": tier}
        except Exception:
            return {"gates": {"dashboard": True, "boards": True, "settings": True}, "tier": 0}

    # ── Dashboard aggregation ────────────────────────────────────────
    @app.get("/api/dashboard")
    def _api_dashboard() -> dict:
        """Aggregate dashboard data: counts, recent activity, tier."""
        try:
            tier_data = _api_user_tier()
            orch = app.state.services.get("orchestrator")
            tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
            tasks_pending = 0
            kanban_pending = 0
            if tq:
                try:
                    tasks_pending = len(tq.get_pending())
                    kanban_items = tq.get_kanban_tasks(status="suggested")
                    kanban_pending = len(kanban_items) if kanban_items else 0
                except Exception:
                    pass
            return {
                "tier": tier_data.get("tier", 0),
                "tier_name": tier_data.get("tier_name", "New"),
                "next_hint": tier_data.get("next_tier_hint", ""),
                "tasks_pending": tasks_pending,
                "kanban_pending": kanban_pending,
                "deployment_mode": tier_data.get("deployment_mode", "local"),
            }
        except Exception:
            return {
                "tier": 0,
                "tier_name": "New",
                "tasks_pending": 0,
                "kanban_pending": 0,
                "deployment_mode": "local",
            }

    # ── Notifications count (for badges) ─────────────────────────────
    @app.get("/api/notifications/count")
    def _api_notifications_count() -> dict:
        """Return notification counts for badge display."""
        orch = app.state.services.get("orchestrator")
        tq = app.state.services.get("task_queue") or (orch.task_queue if orch else None)
        checkins = 0
        kanban_pending = 0
        ready_sessions = 0
        if tq:
            try:
                notifs = tq.get_notifications(limit=100)
                checkins = sum(1 for n in notifs if not n.get("read"))
            except Exception:
                pass
            try:
                kanban_items = tq.get_kanban_tasks(status="suggested")
                kanban_pending = len(kanban_items) if kanban_items else 0
            except Exception:
                pass
        roster = app.state.services.get("roster")
        if roster:
            try:
                sessions = roster._get_sessions()
                ready_sessions = sum(1 for s in sessions if s.get("phase") == "ready")
            except Exception:
                pass
        total = checkins + kanban_pending + ready_sessions
        return {
            "total": total,
            "checkins": checkins,
            "kanban_pending": kanban_pending,
            "ready_sessions": ready_sessions,
        }

    # ── Inbox info (tier-aware instructions) ─────────────────────────
    @app.get("/api/inbox/info")
    def _api_inbox_info() -> dict:
        """Return inbox instruction text based on user tier."""
        tier_data = _api_user_tier()
        tier = tier_data.get("tier", 0)
        cloud_url = os.environ.get("VASTIAN_CLOUD_URL", "https://engine.vastianconnect.com")
        local_path = "saved_prompts/inbox/"
        # Tier 3 (premium/admin) gets cloud upload option
        if tier >= 3:
            return {
                "local_path": local_path,
                "cloud_url": f"{cloud_url}/mobile",
                "cloud_enabled": True,
                "instruction_html": f'Upload via <a href="{cloud_url}/mobile" target="_blank" style="color:#58a6ff">cloud inbox</a> or drop files in <code style="background:#161b22;padding:2px 6px;border-radius:3px">{local_path}</code>',
                "instruction_text": f"Upload via {cloud_url}/mobile or drop files in {local_path}",
            }
        return {
            "local_path": local_path,
            "cloud_url": None,
            "cloud_enabled": False,
            "instruction_html": f'Drop files in <code style="background:#161b22;padding:2px 6px;border-radius:3px">{local_path}</code>',
            "instruction_text": f"Drop files in {local_path}",
        }

    # ── Inbox file upload (desktop + cloud) ────────────────────────
    @app.post("/api/inbox/upload")
    async def _api_inbox_upload(request: Request) -> dict:
        """Accept file upload and write to saved_prompts/inbox/ for processing."""
        try:
            form = await request.form()
            upload = form.get("file")
            if upload is None:
                return JSONResponse({"ok": False, "error": "No file provided"}, status_code=400)

            from vastian.storage.data_root import get_saved_prompts_dir

            project_root = app.state.services.get("project_root") or Path.cwd()
            inbox_dir = get_saved_prompts_dir(project_root) / "inbox"
            inbox_dir.mkdir(parents=True, exist_ok=True)

            from datetime import UTC, datetime

            ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
            safe_name = "".join(
                c if c.isalnum() or c in ".-_" else "_" for c in (upload.filename or "upload.txt")
            )
            dest_name = f"upload-{ts}-{safe_name}"
            content = await upload.read()
            (inbox_dir / dest_name).write_bytes(content)
            return {"ok": True, "filename": dest_name, "size": len(content)}
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    # ── Inbox text submit (type/paste instead of file drop) ─────────────────
    @app.post("/api/inbox/submit")
    async def _api_inbox_submit(request: Request) -> dict:
        """Accept text content and write to saved_prompts/inbox/ for backend intake.

        Same pipeline as file upload: InboxWatcher picks it up and Charles triages.
        """
        try:
            body = await _parse_json_body(request)
            content = (body.get("content") or "").strip()
            if not content:
                return JSONResponse({"ok": False, "error": "content is required"}, status_code=400)
            if len(content) < 10:
                return JSONResponse(
                    {"ok": False, "error": "Content too short (min 10 chars). Add more detail."},
                    status_code=400,
                )

            from datetime import UTC, datetime

            from vastian.storage.data_root import get_saved_prompts_dir

            project_root = app.state.services.get("project_root") or Path.cwd()
            inbox_dir = get_saved_prompts_dir(project_root) / "inbox"
            inbox_dir.mkdir(parents=True, exist_ok=True)
            ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
            dest_name = f"entry-{ts}.txt"
            dest_path = inbox_dir / dest_name
            dest_path.write_text(content, encoding="utf-8")
            return {"ok": True, "filename": dest_name, "size": len(content)}
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    # ── Setup API (first-run wizard) ────────────────────────────────
    @app.get("/api/setup/status")
    def _api_setup_status() -> dict:
        """Check if the system is configured (has API keys or Ollama)."""
        from vastian.config import get_settings

        settings = get_settings()
        from vastian.config_store import is_configured, load_settings_json

        stored = load_settings_json(settings.data_root)
        from vastian.config_store import _settings_path

        path = _settings_path(settings.data_root)
        return {
            "configured": is_configured(settings.data_root),
            "has_api_key": bool(settings.openai_api_key or settings.anthropic_api_key),
            "user_name": settings.user_name,
            "llm_provider": stored.get(
                "llm_provider", str(settings.llm_provider.value) if settings.llm_provider else ""
            ),
            "data_root": str(settings.data_root),
            "settings_path": str(path),
        }

    @app.post("/api/setup")
    async def _api_setup(request: Request) -> dict:
        """Save initial setup config from the wizard."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)

        from vastian.config import get_settings

        settings = get_settings()
        from vastian.config_store import save_settings_json

        config: dict = {}
        if body.get("user_name"):
            config["user_name"] = body["user_name"]
        if body.get("llm_provider"):
            config["llm_provider"] = body["llm_provider"]
        if body.get("openai_api_key"):
            config["openai_api_key"] = body["openai_api_key"]
        if body.get("anthropic_api_key"):
            config["anthropic_api_key"] = body["anthropic_api_key"]
        if body.get("ollama_model"):
            config["ollama_model"] = body["ollama_model"]
        if body.get("default_model"):
            config["default_model"] = body["default_model"]

        save_settings_json(settings.data_root, config)

        # Also set in current process env so it takes effect immediately
        import os

        for key, val in config.items():
            env_map = {
                "user_name": "VASTIAN_USER_NAME",
                "llm_provider": "VASTIAN_LLM_PROVIDER",
                "openai_api_key": "OPENAI_API_KEY",
                "anthropic_api_key": "ANTHROPIC_API_KEY",
                "default_model": "VASTIAN_DEFAULT_MODEL",
                "ollama_model": "OLLAMA_MODEL",
            }
            if key in env_map and val:
                os.environ[env_map[key]] = str(val)

        return {"ok": True, "saved_keys": list(config.keys())}

    @app.post("/api/onboard")
    async def _api_onboard() -> dict:
        """Trigger onboarding: enqueue task from saved_prompts/intro.txt and run in background."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        from vastian.storage.data_root import get_data_root, get_saved_prompts_dir

        get_data_root(project_root)  # ensure data root is valid
        intro_path = get_saved_prompts_dir(project_root) / "intro.txt"
        if not intro_path.exists():
            return JSONResponse(
                {
                    "ok": False,
                    "error": "saved_prompts/intro.txt not found. Write about yourself first.",
                },
                status_code=400,
            )
        try:
            intro_content = intro_path.read_text(encoding="utf-8").strip()
        except OSError as e:
            return JSONResponse(
                {"ok": False, "error": f"Could not read intro.txt: {e}"},
                status_code=400,
            )
        if len(intro_content) < 50:
            return JSONResponse(
                {
                    "ok": False,
                    "error": "intro.txt is too short. Write at least a paragraph (50+ chars).",
                },
                status_code=400,
            )
        orchestrator = app.state.services.get("orchestrator")
        if not orchestrator:
            return JSONResponse(
                {
                    "ok": False,
                    "error": "No orchestrator. Start with 'vastian serve' or 'vastian chat'.",
                },
                status_code=503,
            )
        tq = app.state.services.get("task_queue") or getattr(orchestrator, "task_queue", None)
        if not tq:
            return JSONResponse(
                {"ok": False, "error": "Task queue not initialized."},
                status_code=503,
            )
        task = tq.enqueue(
            task_type="onboarding",
            agent_id="charles",
            description="Onboarding: populate all agent docs from intro.txt",
            payload={"intro_path": str(intro_path)},
        )
        asyncio.create_task(orchestrator._execute_background_task(task.task_id))
        return {"ok": True, "task_id": task.task_id}

    # ── Agent Documents API ────────────────────────────────────────
    @app.get("/api/agent-docs")
    def _api_agent_docs_list() -> list[dict]:
        """List all agents and their owned documents."""
        from vastian.config import get_settings

        settings = get_settings()
        docs_dir = settings.docs_dir
        agents_dir = Path(__file__).resolve().parents[3] / "agents"
        result: list[dict] = []
        import glob

        yaml_files = sorted(glob.glob(str(agents_dir / "*.yaml")))
        for yf in yaml_files:
            try:
                import yaml  # type: ignore[import-untyped]

                with open(yf, encoding="utf-8") as f:
                    data = yaml.safe_load(f) or {}
            except Exception:
                continue
            agent_id = data.get("id", Path(yf).stem)
            name = data.get("name", agent_id.title())
            title = data.get("title", "")
            owned = data.get("owned_documents", []) or []
            docs_list = []
            for doc_name in owned:
                doc_path = docs_dir / agent_id / doc_name
                docs_list.append(
                    {
                        "name": doc_name,
                        "path": f"{agent_id}/{doc_name}",
                        "exists": doc_path.exists(),
                    }
                )
            result.append(
                {
                    "agent_id": agent_id,
                    "name": name,
                    "title": title,
                    "docs": docs_list,
                }
            )
        return result

    @app.get("/api/agent-docs/{agent_id}/{doc_name}", response_model=None)
    def _api_agent_doc_read(agent_id: str, doc_name: str) -> dict | JSONResponse:
        """Read a single agent document as markdown."""
        from vastian.config import get_settings

        settings = get_settings()
        # Ensure .md extension
        if not doc_name.endswith(".md"):
            doc_name = doc_name + ".md"
        doc_path = settings.docs_dir / agent_id / doc_name
        if not doc_path.exists():
            return JSONResponse(
                {"error": f"Document not found: {agent_id}/{doc_name}"},
                status_code=404,
            )
        content = doc_path.read_text(encoding="utf-8")
        stat = doc_path.stat()
        from datetime import UTC, datetime

        last_modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
        return {
            "agent_id": agent_id,
            "doc_name": doc_name,
            "content": content,
            "last_modified": last_modified,
        }

    @app.post("/api/agent-docs/{agent_id}/{doc_name}")
    async def _api_agent_doc_write(agent_id: str, doc_name: str, request: Request) -> dict:
        """Save content to an agent document."""
        from vastian.config import get_settings

        settings = get_settings()
        if not doc_name.endswith(".md"):
            doc_name = doc_name + ".md"
        doc_path = settings.docs_dir / agent_id / doc_name
        doc_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            body = await request.json()
            content = body.get("content", "")
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON body"}, status_code=400)
        doc_path.write_text(content, encoding="utf-8")
        return {"ok": True, "agent_id": agent_id, "doc_name": doc_name, "size": len(content)}

    # ── Pending Doc Edits (diff review) ──────────────────────────────
    _pending_doc_edits: list[dict] = []

    @app.get("/api/doc-edits/pending")
    def _api_pending_edits() -> dict:
        """List pending doc edits awaiting user review."""
        # Merge edits from orchestrator and local queue
        orch_edits = getattr(orchestrator, "_pending_doc_edits", []) if orchestrator else []
        all_edits = _pending_doc_edits + orch_edits
        return {"edits": all_edits, "count": len(all_edits)}

    @app.post("/api/doc-edits/submit")
    async def _api_submit_edit(request: Request) -> dict:
        """Queue a doc edit for user review (called by orchestrator or agent)."""
        import time

        body = await request.json()
        edit = {
            "id": f"edit-{int(time.time() * 1000)}",
            "agent_id": body.get("agent_id", ""),
            "doc_path": body.get("doc_path", ""),
            "old_content": body.get("old_content", ""),
            "new_content": body.get("new_content", ""),
            "summary": body.get("summary", ""),
            "timestamp": time.time(),
        }
        _pending_doc_edits.append(edit)
        return {"ok": True, "edit_id": edit["id"]}

    @app.post("/api/doc-edits/{edit_id}/accept")
    async def _api_accept_edit(edit_id: str) -> dict:
        """Accept a pending edit — write to disk."""
        from vastian.config import get_settings

        settings = get_settings()
        # Search both local and orchestrator queues
        all_queues = [
            _pending_doc_edits,
            getattr(orchestrator, "_pending_doc_edits", []) if orchestrator else [],
        ]
        for queue in all_queues:
            for i, edit in enumerate(queue):
                if edit["id"] == edit_id:
                    doc_path = settings.docs_dir / edit["doc_path"]
                    doc_path.parent.mkdir(parents=True, exist_ok=True)
                    doc_path.write_text(edit["new_content"], encoding="utf-8")
                    queue.pop(i)
                    return {"ok": True, "written": edit["doc_path"]}
        return JSONResponse({"ok": False, "error": "Edit not found"}, status_code=404)

    @app.post("/api/doc-edits/{edit_id}/reject")
    async def _api_reject_edit(edit_id: str) -> dict:
        """Reject and discard a pending edit."""
        all_queues = [
            _pending_doc_edits,
            getattr(orchestrator, "_pending_doc_edits", []) if orchestrator else [],
        ]
        for queue in all_queues:
            for i, edit in enumerate(queue):
                if edit["id"] == edit_id:
                    queue.pop(i)
                    return {"ok": True, "rejected": edit_id}
        return JSONResponse({"ok": False, "error": "Edit not found"}, status_code=404)

    # ── Roadmap API ─────────────────────────────────────────────────
    _DEFAULT_ROADMAP = {  # noqa: N806
        "stages": [
            {
                "id": "intro",
                "session_type": "onboarding",
                "topic": "Tell us about yourself",
                "unlocks": ["boards"],
            },
            {
                "id": "goals",
                "session_type": "onboarding",
                "topic": "Your goals and priorities",
                "unlocks": ["metrics"],
            },
            {
                "id": "network",
                "session_type": "onboarding",
                "topic": "Your people and mentors",
                "unlocks": ["councils"],
            },
            {
                "id": "growth",
                "session_type": "onboarding",
                "topic": "Growth areas and challenges",
                "unlocks": ["scenarios", "sessions", "personas"],
            },
            {
                "id": "planning",
                "session_type": "cycle_plan",
                "topic": "First cycle plan",
                "unlocks": ["plans"],
            },
        ],
        "current_stage": 0,
        "completed": [],
    }

    def _get_roadmap() -> dict:
        """Load or seed the onboarding roadmap."""
        try:
            from vastian.storage.data_root import get_data_root

            project_root = app.state.services.get("project_root") or Path.cwd()
            roadmap_file = get_data_root(project_root) / "state" / "roadmap.json"
            if roadmap_file.exists():
                return json.loads(roadmap_file.read_text(encoding="utf-8"))
            # Seed default
            roadmap_file.parent.mkdir(parents=True, exist_ok=True)
            roadmap_file.write_text(json.dumps(_DEFAULT_ROADMAP, indent=2), encoding="utf-8")
            return dict(_DEFAULT_ROADMAP)
        except Exception:
            return dict(_DEFAULT_ROADMAP)

    def _save_roadmap(roadmap: dict) -> None:
        try:
            from vastian.storage.data_root import get_data_root

            project_root = app.state.services.get("project_root") or Path.cwd()
            roadmap_file = get_data_root(project_root) / "state" / "roadmap.json"
            roadmap_file.parent.mkdir(parents=True, exist_ok=True)
            roadmap_file.write_text(json.dumps(roadmap, indent=2), encoding="utf-8")
        except Exception:
            pass

    @app.get("/api/roadmap/status")
    def _api_roadmap_status() -> dict:
        """Return current roadmap stage, progress, next unlock hint."""
        roadmap = _get_roadmap()
        stages = roadmap.get("stages", [])
        current_idx = roadmap.get("current_stage", 0)
        completed = roadmap.get("completed", [])
        total = len(stages)
        if current_idx < total:
            current = stages[current_idx]
            next_unlocks = current.get("unlocks", [])
            return {
                "stage": current.get("id", ""),
                "topic": current.get("topic", ""),
                "progress": round(len(completed) / max(total, 1), 2),
                "completed_count": len(completed),
                "total_stages": total,
                "next_unlocks": next_unlocks,
            }
        return {
            "stage": "complete",
            "topic": "",
            "progress": 1.0,
            "completed_count": total,
            "total_stages": total,
            "next_unlocks": [],
        }

    @app.get("/api/roadmap/full")
    def _api_roadmap_full() -> dict:
        """Admin-only: return the full roadmap for debugging."""
        return _get_roadmap()

    @app.post("/api/roadmap/advance")
    async def _api_roadmap_advance(request: Request) -> dict:
        """Mark the current roadmap stage as completed and advance."""
        roadmap = _get_roadmap()
        stages = roadmap.get("stages", [])
        current_idx = roadmap.get("current_stage", 0)
        if current_idx >= len(stages):
            return {"ok": True, "message": "Roadmap already complete"}
        completed_stage = stages[current_idx]
        roadmap.setdefault("completed", []).append(completed_stage)
        roadmap["current_stage"] = current_idx + 1
        _save_roadmap(roadmap)
        return {
            "ok": True,
            "advanced_to": current_idx + 1,
            "unlocked": completed_stage.get("unlocks", []),
        }

    # ── Scenarios API ────────────────────────────────────────────────
    @app.get("/api/scenarios")
    def _api_scenarios() -> dict:
        try:
            from vastian.scenario_loader import load_active_scenarios

            return {"scenarios": load_active_scenarios()}
        except Exception:
            return {"scenarios": []}

    @app.get("/api/scenarios/{scenario_id}")
    def _api_scenario_detail(scenario_id: str) -> Any:
        try:
            from vastian.scenario_loader import load_scenario_by_id

            s = load_scenario_by_id(scenario_id)
            if not s:
                return JSONResponse(content={"error": "Scenario not found"}, status_code=404)
            return s
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)

    # ── Plans API ─────────────────────────────────────────────────
    @app.get("/api/plans/health")
    def _api_plans_health() -> dict:
        try:
            from vastian.plans_loader import list_health_plans, load_health_plan

            names = list_health_plans()
            plans = []
            for n in names:
                p = load_health_plan(n)
                if p:
                    p.setdefault("id", n)
                    plans.append(p)
            return {"plans": plans}
        except Exception:
            return {"plans": []}

    @app.get("/api/plans/spiritual")
    def _api_plans_spiritual() -> dict:
        try:
            from vastian.plans_loader import list_spiritual_plans, load_spiritual_plan

            names = list_spiritual_plans()
            plans = []
            for n in names:
                p = load_spiritual_plan(n)
                if p:
                    p.setdefault("id", n)
                    plans.append(p)
            return {"plans": plans}
        except Exception:
            return {"plans": []}

    @app.get("/api/plans/{plan_type}/{name}")
    def _api_plan_detail(plan_type: str, name: str) -> Any:
        try:
            if plan_type == "health":
                from vastian.plans_loader import load_health_plan

                p = load_health_plan(name)
            elif plan_type == "spiritual":
                from vastian.plans_loader import load_spiritual_plan

                p = load_spiritual_plan(name)
            else:
                return JSONResponse(content={"error": "Invalid plan type"}, status_code=400)
            if not p:
                return JSONResponse(content={"error": "Plan not found"}, status_code=404)
            p.setdefault("id", name)
            return p
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)

    # ── Metrics API ───────────────────────────────────────────────
    @app.get("/api/metrics/definitions")
    def _api_metrics_definitions() -> dict:
        try:
            from vastian.scenario_loader import load_metrics_definitions

            return {"definitions": load_metrics_definitions()}
        except Exception:
            return {"definitions": {}}

    @app.get("/api/metrics/baselines")
    def _api_metrics_baselines() -> dict:
        try:
            from vastian.scenario_loader import load_metrics_baselines

            return load_metrics_baselines()
        except Exception:
            return {"defaults": {}}

    @app.get("/api/metrics/blueprints")
    def _api_metrics_blueprints() -> dict:
        try:
            from vastian.scenario_loader import load_all_blueprints

            return {"blueprints": load_all_blueprints()}
        except Exception:
            return {"blueprints": []}

    @app.get("/api/metrics/domain-sets")
    def _api_metrics_domain_sets() -> dict:
        try:
            from vastian.scenario_loader import load_metrics_for_jake, load_metrics_for_kiran

            return {
                "jake_metrics": load_metrics_for_jake(),
                "kiran_metrics": load_metrics_for_kiran(),
            }
        except Exception:
            return {"jake_metrics": [], "kiran_metrics": []}

    # ── Meetings API ──────────────────────────────────────────────
    @app.get("/api/meetings")
    def _api_meetings() -> dict:
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            meeting_dir = get_data_root(pr) / "state" / "content-bridge" / "meeting"
            meetings: list[dict] = []
            if meeting_dir.exists():
                for f in sorted(meeting_dir.iterdir(), reverse=True):
                    if f.is_file() and f.suffix in (".json", ".yaml", ".md", ".txt"):
                        meetings.append(
                            {
                                "name": f.stem,
                                "file": f.name,
                                "modified": f.stat().st_mtime,
                            }
                        )
            return {"meetings": meetings}
        except Exception:
            return {"meetings": []}

    @app.post("/api/meetings/upload")
    async def _api_meetings_upload(request: Request) -> dict:
        """Upload a meeting transcript or AI summary file."""
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            form = await request.form()
            upload = form.get("file")
            if upload is None:
                return JSONResponse({"ok": False, "error": "No file provided"}, status_code=400)

            # Validate file type
            filename = upload.filename or "transcript.txt"
            suffix = Path(filename).suffix.lower()
            if suffix not in (".txt", ".md", ".vtt", ".srt", ".json", ".yaml"):
                return JSONResponse(
                    {"ok": False, "error": f"Unsupported file type: {suffix}"},
                    status_code=400,
                )

            pr = app.state.services.get("project_root") or Path.cwd()
            meeting_dir = get_data_root(pr) / "state" / "content-bridge" / "meeting"
            meeting_dir.mkdir(parents=True, exist_ok=True)

            # Save with timestamp prefix
            from datetime import UTC, datetime

            ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
            safe_name = "".join(c if c.isalnum() or c in ".-_" else "_" for c in filename)
            dest_name = f"{ts}-{safe_name}"
            content = await upload.read()
            dest_path = meeting_dir / dest_name
            dest_path.write_bytes(content)

            # Trigger Maya signal extraction as background task
            orch = app.state.services.get("orchestrator")
            if orch and hasattr(orch, "task_queue") and orch.task_queue:
                orch.task_queue.enqueue(
                    agent_id="maya",
                    description=f"Extract signals from uploaded meeting transcript: {dest_name}",
                    context=f"Meeting file uploaded at {dest_path}. Read the file and extract key signals (decisions, action items, blockers, risks, ideas).",
                )

            return {"ok": True, "filename": dest_name, "size": len(content)}
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    # ── Suggestions API (notification/approval flow) ────────────────
    @app.get("/api/suggestions")
    def _api_suggestions_get(
        status: str = "pending",
        type: str | None = None,  # noqa: A002
    ) -> dict:
        """Get suggestions, optionally filtered by status and type."""
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.task_queue:
            return {"suggestions": []}
        try:
            suggestions = orch.task_queue.get_suggestions(status=status, suggestion_type=type)
            return {"suggestions": suggestions}
        except Exception:
            return {"suggestions": []}

    @app.get("/api/notifications/count")
    def _api_notification_counts() -> dict:
        """Get per-category pending suggestion counts for badge display."""
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.task_queue:
            return {"total": 0}
        try:
            return orch.task_queue.get_suggestion_counts()
        except Exception:
            return {"total": 0}

    @app.post("/api/suggestions/{suggestion_id}/accept")
    def _api_suggestion_accept(suggestion_id: str) -> dict:
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.task_queue:
            return JSONResponse({"ok": False, "error": "No task queue"}, status_code=500)
        ok = orch.task_queue.resolve_suggestion(suggestion_id, "accepted")
        if ok:
            return {"ok": True, "message": "Suggestion accepted"}
        return JSONResponse(
            {"ok": False, "error": "Not found or already resolved"}, status_code=404
        )

    @app.post("/api/suggestions/{suggestion_id}/reject")
    def _api_suggestion_reject(suggestion_id: str) -> dict:
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.task_queue:
            return JSONResponse({"ok": False, "error": "No task queue"}, status_code=500)
        ok = orch.task_queue.resolve_suggestion(suggestion_id, "rejected")
        if ok:
            return {"ok": True, "message": "Suggestion rejected"}
        return JSONResponse(
            {"ok": False, "error": "Not found or already resolved"}, status_code=404
        )

    # ── Token Usage API ───────────────────────────────────────────
    @app.get("/api/token-usage")
    def _api_token_usage(agent_id: str | None = None, since: str | None = None) -> dict:
        orch = app.state.services.get("orchestrator")
        if not orch or not orch.task_queue:
            return {"usage": []}
        try:
            return {"usage": orch.task_queue.get_token_usage(agent_id=agent_id, since=since)}
        except Exception:
            return {"usage": []}

    # ── Admin Tier Simulation API ─────────────────────────────────
    @app.post("/api/admin/set-tier")
    async def _api_admin_set_tier(request: Request) -> dict:
        """Set the simulated user tier for progressive unlock testing."""
        body = await _parse_json_body(request)
        tier = body.get("tier")
        if tier is None or not isinstance(tier, int) or tier < 0 or tier > 3:
            return JSONResponse({"ok": False, "error": "tier must be 0-3"}, status_code=400)
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            tier_file = dr / "state" / "user_tier.json"
            tier_file.parent.mkdir(parents=True, exist_ok=True)
            import json as _json

            tier_file.write_text(_json.dumps({"tier": tier, "override": True}), encoding="utf-8")
            return {"ok": True, "tier": tier, "message": f"Simulating Tier {tier}"}
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    @app.get("/api/admin/tier")
    def _api_admin_tier_get() -> dict:
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            tier_file = dr / "state" / "user_tier.json"
            if tier_file.exists():
                import json as _json

                data = _json.loads(tier_file.read_text(encoding="utf-8"))
                return {"tier": data.get("tier", -1), "override": data.get("override", False)}
            return {"tier": -1, "override": False}
        except Exception:
            return {"tier": -1, "override": False}

    @app.delete("/api/admin/tier")
    def _api_admin_tier_reset() -> dict:
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            tier_file = dr / "state" / "user_tier.json"
            if tier_file.exists():
                tier_file.unlink()
            return {"ok": True, "message": "Tier override cleared"}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ── Roleplay Sessions API ─────────────────────────────────────
    @app.get("/api/roleplay/sessions")
    def _api_roleplay_sessions() -> dict:
        """Get all roleplay sessions (active + history).

        Auto-expires incomplete sessions older than 7 days (configurable
        via config/local/settings.json → roleplay_expiry_days).
        """
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root
            from vastian.storage.file_store import FileStore

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            sessions_file = dr / "state" / "roleplay-sessions.json"
            if not sessions_file.exists():
                return {"sessions": []}

            store = FileStore(sessions_file, id_field="session_id")
            sessions = store.load_all()

            # Auto-expire stale active sessions
            from datetime import UTC, datetime

            expiry_days = 7
            try:
                from vastian.config_store import load_settings_json

                sj = load_settings_json(dr)
                expiry_days = int(sj.get("roleplay_expiry_days", 7))
            except Exception:
                pass

            now = datetime.now(UTC)
            mutated = False
            for s in sessions:
                if s.get("status") == "active":
                    updated = s.get("updated_at") or s.get("created_at", "")
                    if updated:
                        try:
                            dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
                            if (now - dt).days > expiry_days:
                                s["status"] = "expired"
                                mutated = True
                        except Exception:
                            pass
            if mutated:
                store.replace_all(sessions)

            return {"sessions": sessions}
        except Exception:
            return {"sessions": []}

    @app.post("/api/roleplay/sessions")
    async def _api_roleplay_session_create(request: Request) -> dict:
        """Create or update a roleplay session."""
        body = await _parse_json_body(request)
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root
            from vastian.storage.file_store import FileStore

            pr = app.state.services.get("project_root") or Path.cwd()
            store = FileStore(
                get_data_root(pr) / "state" / "roleplay-sessions.json", id_field="session_id"
            )
            session_id = body.get("session_id", "")
            if session_id:
                # Update existing
                updates = {k: v for k, v in body.items() if k != "session_id"}
                store.update(session_id, updates)
                return {"ok": True, "session_id": session_id}
            else:
                # Create new
                import uuid as _uuid
                from datetime import UTC, datetime

                sid = f"rp-{_uuid.uuid4().hex[:10]}"
                record = {
                    "session_id": sid,
                    "persona_id": body.get("persona_id", ""),
                    "agent_id": body.get("agent_id", "adrian"),
                    "title": body.get("title", "Roleplay Session"),
                    "status": "active",
                    "narrative": [],
                    "choices": [],
                    "created_at": datetime.now(UTC).isoformat(),
                    "updated_at": datetime.now(UTC).isoformat(),
                }
                store.insert(record)
                return {"ok": True, "session_id": sid}
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    @app.get("/api/roleplay/sessions/{session_id}")
    def _api_roleplay_session_get(session_id: str) -> dict:
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root
            from vastian.storage.file_store import FileStore

            pr = app.state.services.get("project_root") or Path.cwd()
            store = FileStore(
                get_data_root(pr) / "state" / "roleplay-sessions.json", id_field="session_id"
            )
            session = store.get(session_id)
            if session:
                return {"session": session}
            return JSONResponse({"error": "Session not found"}, status_code=404)
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    # ── Personas (activities + challenges) API ────────────────────
    @app.get("/api/personas/activities")
    def _api_personas_activities() -> dict:
        try:
            from pathlib import Path

            import yaml

            from vastian.storage.data_root import get_config_dir

            pr = app.state.services.get("project_root") or Path.cwd()
            ojas_dir = get_config_dir(pr) / "personas" / "ojas"
            activities: list[dict] = []
            activity_types: list[dict] = []
            act_file = ojas_dir / "activities.yaml"
            if act_file.exists():
                data = yaml.safe_load(act_file.read_text(encoding="utf-8"))
                if isinstance(data, list):
                    activities = data
                elif isinstance(data, dict):
                    activities = data.get("activities", [])
            types_file = ojas_dir / "activity_types.yaml"
            if types_file.exists():
                data = yaml.safe_load(types_file.read_text(encoding="utf-8"))
                if isinstance(data, list):
                    activity_types = data
                elif isinstance(data, dict):
                    activity_types = data.get("types", data.get("activity_types", []))
            return {"activities": activities, "activity_types": activity_types}
        except Exception:
            return {"activities": [], "activity_types": []}

    @app.get("/api/personas/challenges")
    def _api_personas_challenges() -> dict:
        try:
            from vastian.scenario_loader import load_scenario_challenges

            return {"challenges": load_scenario_challenges()}
        except Exception:
            return {"challenges": []}

    # ── Data root API ─────────────────────────────────────────────
    @app.get("/api/settings/data-root")
    def _api_data_root_get() -> dict:
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            return {"data_root": str(get_data_root(pr))}
        except Exception as e:
            return {"data_root": "", "error": str(e)}

    @app.get("/api/first-run")
    def _api_first_run() -> dict:
        """Detect first-run state: no kanban tasks and no intro file."""
        try:
            from pathlib import Path

            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            kanban = dr / "state" / "kanban.json"
            intro = dr / "saved_prompts" / "intro.txt"
            has_kanban = False
            if kanban.exists():
                import json as _json

                data = _json.loads(kanban.read_text(encoding="utf-8"))
                has_kanban = isinstance(data, list) and len(data) > 0
            has_intro = intro.exists() and intro.stat().st_size > 10
            return {
                "first_run": not has_kanban and not has_intro,
                "has_kanban": has_kanban,
                "has_intro": has_intro,
                "data_root": str(dr),
            }
        except Exception:
            return {"first_run": False}

    @app.post("/api/settings/data-root")
    async def _api_data_root_set(request: Request) -> Any:
        body = await _parse_json_body(request)
        path = (body.get("path") or "").strip()
        if not path:
            return JSONResponse(content={"error": "path is required"}, status_code=400)
        try:
            from pathlib import Path

            from vastian.storage.data_root import set_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            ok, err = set_data_root(pr, path)
            if not ok:
                return JSONResponse(content={"error": err}, status_code=400)
            return {
                "ok": True,
                "data_root": path,
                "message": "Data root updated. Restart required.",
            }
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)

    # ── Storage Provider Settings API ────────────────────────────────
    @app.get("/api/settings/storage")
    def _api_storage_get() -> dict:
        """Get current storage provider config."""
        try:
            from pathlib import Path

            from vastian.config_store import load_settings_json
            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            sj = load_settings_json(dr)
            return {
                "provider": sj.get("storage_provider", "local"),
                "options": ["local", "google_drive", "dropbox", "box", "tresorit"],
            }
        except Exception as e:
            return {"provider": "local", "error": str(e)}

    @app.post("/api/settings/storage")
    async def _api_storage_set(request: Request) -> dict:
        """Update the storage provider and optionally trigger migration."""
        body = await _parse_json_body(request)
        provider = (body.get("provider") or "").strip().lower()
        valid = {"local", "google_drive", "dropbox", "box", "tresorit"}
        if provider not in valid:
            return JSONResponse(
                {
                    "ok": False,
                    "error": f"Invalid provider. Choose from: {', '.join(sorted(valid))}",
                },
                status_code=400,
            )
        try:
            from pathlib import Path

            from vastian.config_store import set_config_value
            from vastian.storage.data_root import get_data_root

            pr = app.state.services.get("project_root") or Path.cwd()
            dr = get_data_root(pr)
            set_config_value("storage_provider", provider, dr)

            # If migration requested, queue as background task
            migrate = body.get("migrate", False)
            if migrate:
                orch = app.state.services.get("orchestrator")
                if orch and hasattr(orch, "task_queue") and orch.task_queue:
                    orch.task_queue.enqueue(
                        agent_id="felix",
                        description=f"Migrate storage to {provider}",
                        context=f"User requested storage migration to {provider}. Coordinate data transfer.",
                    )
                return {
                    "ok": True,
                    "provider": provider,
                    "message": f"Provider set to {provider}. Migration queued.",
                }
            return {
                "ok": True,
                "provider": provider,
                "message": f"Storage provider set to {provider}.",
            }
        except Exception as e:
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    feed = app.state.services.get("feed")
    roster = app.state.services.get("roster")
    chart = app.state.services.get("chart")
    settings_svc = app.state.services.get("settings")

    # ── Vastian: Unified shell at / ──────────────────────────────────
    user_tier = app.state.services.get("user_tier", "open")
    project_root = app.state.services.get("project_root")

    @app.get("/", response_class=HTMLResponse)
    def _unified_shell() -> str:
        from vastian.server.unified_shell import render_shell

        return render_shell(user_tier=user_tier, project_root=project_root)

    # ── Wave 3: Meetings, Councils, Personas views ────────────────────────
    @app.get("/meetings", response_class=HTMLResponse)
    def _meetings_html() -> str:
        from vastian.server.views import render_meetings_view

        return render_meetings_view(project_root=project_root)

    @app.get("/councils", response_class=HTMLResponse)
    def _councils_html() -> str:
        from vastian.server.views import render_councils_view

        return render_councils_view(user_tier=user_tier, project_root=project_root)

    # ── Feed routes (at /feed for shell iframe) ─────────────────────────
    if feed:

        @app.get("/feed", response_class=HTMLResponse)
        def _feed_html() -> str:
            return feed._render_html(project_root=project_root)

        @app.get("/api/notifications")
        def _api_notifications() -> dict:
            return feed._get_feed_data()

        @app.get("/api/agents")
        def _api_agents() -> list:
            from vastian.tasks.feed import AGENT_META

            return AGENT_META

        @app.get("/api/agents/{agent_id}/profile")
        def _api_agent_profile(agent_id: str) -> dict:
            """Rich agent profile with sigil, role, responsibilities, tools, color."""
            agents_dir = Path(__file__).resolve().parents[3] / "agents"
            yaml_path = agents_dir / f"{agent_id}.yaml"
            if not yaml_path.exists():
                return JSONResponse({"error": "Agent not found"}, status_code=404)
            import yaml  # type: ignore[import-untyped]

            with open(yaml_path, encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
            # Tool friendly names
            tool_friendly: dict[str, str] = {
                "delegate_to_agent": "Delegate Tasks",
                "batch_delegate": "Batch Delegate",
                "targeted_batch_delegate": "Targeted Batch Delegate",
                "read_document": "Read Documents",
                "write_document": "Write Documents",
                "search_knowledge": "Search Knowledge",
                "suggest_kanban_task": "Suggest Tasks",
                "create_bank_transaction": "Finance Ledger",
                "score_persona_traits": "Score Persona Traits",
                "load_persona": "Load Persona",
                "suggest_config_change": "Suggest Config",
                "spawn_sub_agent": "Spawn Sub-Agent",
                "create_request": "Create Request",
                "write_giga_plan": "Write Plan",
                "write_giga_neuron": "Write Neuron",
                "suggest_activity": "Suggest Activity",
                "ask_structured_question": "Ask Question",
            }
            tools_raw = data.get("tools", [])
            tools = [
                {"id": t, "name": tool_friendly.get(t, t.replace("_", " ").title())}
                for t in tools_raw
            ]
            # Agent-specific colors (match frontend AGENT_COLORS)
            agent_colors = {
                "charles": "#58a6ff",
                "dominic": "#bc8cff",
                "liam": "#3fb950",
                "henry": "#f0883e",
                "finley": "#56d364",
                "theo": "#d2a8ff",
                "elias": "#79c0ff",
                "victor": "#a371f7",
                "adrian": "#ffa657",
                "concord": "#7ee787",
                "jake": "#39d353",
                "orion": "#e3b341",
                "rowan": "#ff7b72",
                "arjuna": "#58a6ff",
                "maya": "#f778ba",
                "noah": "#79c0ff",
                "felix": "#3fb950",
                "maria": "#d29922",
                "sage": "#a5d6ff",
                "kiran": "#ff9bce",
            }
            # Resolve agent level/tier from AGENT_META
            from vastian.tasks.feed import AGENT_META

            meta = next((a for a in AGENT_META if a["id"] == agent_id), {})

            return {
                "agent_id": data.get("agent_id", agent_id),
                "name": data.get("name", agent_id.title()),
                "title": data.get("title", ""),
                "sigil": data.get("sigil", ""),
                "tone": (data.get("tone", "") or "")[:200],
                "role_description": (data.get("role_description", "") or "")[:400],
                "responsibilities": data.get("responsibilities", []),
                "owned_documents": data.get("owned_documents", []),
                "tools": tools,
                "reports_to": data.get("reports_to", ""),
                "teams": data.get("teams", []),
                "color": agent_colors.get(agent_id, "#8b949e"),
                "level": meta.get("level", 3),
                "tier": meta.get("tier", "backend"),
            }

        @app.get("/api/kanban")
        def _api_kanban(board: str | None = None) -> dict:
            return feed._get_kanban_data(board=board)

        @app.get("/api/history")
        def _api_history() -> dict:
            return feed._get_history_data()

        @app.get("/api/sync-suggestions")
        def _api_sync_suggestions() -> dict:
            return feed._get_sync_suggestions()

        @app.post("/api/kanban/assign-me")
        async def _api_kanban_assign_me(request: Request) -> dict:
            body = await _parse_json_body(request)
            return feed._assign_kanban_to_user(body.get("kanban_id", ""))

        @app.post("/api/kanban/approve-start")
        async def _api_kanban_approve_start(request: Request) -> dict:
            body = await _parse_json_body(request)
            kanban_id = body.get("kanban_id", "")
            result = feed._approve_kanban_start(kanban_id)
            if result.get("ok") and feed.on_kanban_approved:
                main_loop = app.state.services.get("main_loop")
                if main_loop:
                    main_loop.call_soon_threadsafe(
                        lambda kid=kanban_id: asyncio.create_task(feed.on_kanban_approved(kid))
                    )
            return result

        @app.post("/api/kanban/approve-result")
        async def _api_kanban_approve_result(request: Request) -> dict:
            body = await _parse_json_body(request)
            return feed._approve_kanban_result(body.get("kanban_id", ""))

        @app.post("/api/kanban/reject")
        async def _api_kanban_reject(request: Request) -> dict:
            body = await _parse_json_body(request)
            return feed._reject_kanban(
                body.get("kanban_id", ""),
                body.get("notes", ""),
            )

        @app.post("/api/kanban/reject-suggestion")
        async def _api_kanban_reject_suggestion(request: Request) -> dict:
            body = await _parse_json_body(request)
            return feed._reject_kanban_suggestion(body.get("kanban_id", ""))

        @app.post("/api/kanban/archive")
        async def _api_kanban_archive(request: Request) -> dict:
            body = await _parse_json_body(request)
            return feed._archive_kanban_task(body.get("kanban_id", ""))

        @app.post("/api/task/force-start")
        async def _api_task_force_start(request: Request) -> dict:
            body = await _parse_json_body(request)
            task_id = body.get("task_id", "")
            result = feed._force_start_task(task_id)
            if result.get("ok") and feed.on_force_start:
                main_loop = app.state.services.get("main_loop")
                if main_loop:
                    main_loop.call_soon_threadsafe(
                        lambda tid=task_id: asyncio.create_task(feed.on_force_start(tid))
                    )
            return result

        @app.get("/docs", response_model=None)
        @app.get("/docs/{path:path}", response_model=None)
        def _docs(path: str = ""):
            full_path = "/docs" + ("/" + path if path else "")
            html, status = feed._render_doc(full_path)
            if status != 200:
                return JSONResponse(content={"error": "Not found"}, status_code=status)
            return HTMLResponse(content=html)

    # ── Roster routes ──────────────────────────────────────────────────
    if roster:

        @app.get("/roster", response_class=HTMLResponse)
        def _roster_html() -> str:
            return roster._render_html(project_root=project_root)

        @app.get("/api/session-types")
        def _api_session_types() -> list:
            return roster._get_session_types()

        @app.get("/api/sessions")
        def _api_sessions() -> list:
            return roster._get_sessions()

        @app.get("/api/session/{session_id}")
        def _api_session_detail(session_id: str) -> dict:
            return roster._get_session_detail(session_id)

        @app.post("/api/sessions/create")
        async def _api_sessions_create(request: Request) -> dict:
            body = await _parse_json_body(request)
            return roster._create_session(body)

        @app.post("/api/sessions/complete")
        async def _api_sessions_complete(request: Request) -> dict:
            body = await _parse_json_body(request)
            return roster._complete_session(body)

        @app.post("/api/sessions/answer")
        async def _api_sessions_answer(request: Request) -> dict:
            body = await _parse_json_body(request)
            return roster._save_card_answer(body)

        @app.post("/api/sessions/config-approve")
        async def _api_sessions_config_approve(request: Request) -> dict:
            body = await _parse_json_body(request)
            return roster._config_approve(body)

    # ── Persona chart routes ───────────────────────────────────────────
    if chart:

        @app.get("/persona", response_class=HTMLResponse)
        def _persona_html() -> str:
            return chart._render_html()

        @app.get("/personas", response_class=HTMLResponse)
        def _personas_html() -> str:
            from vastian.server.views import render_personas_view

            return render_personas_view(project_root=project_root)

        @app.get("/api/persona-data")
        def _api_persona_data() -> dict:
            return chart._get_data()

    # ── Settings routes ────────────────────────────────────────────────
    if settings_svc:

        @app.get("/settings", response_class=HTMLResponse)
        def _settings_html() -> str:
            from vastian.tasks.settings_page import SETTINGS_HTML

            return SETTINGS_HTML

        @app.get("/api/settings")
        def _api_settings_get() -> dict:
            return settings_svc._get_settings()

        @app.post("/api/settings")
        async def _api_settings_post(request: Request) -> dict:
            body = await _parse_json_body(request)
            if "agent_models_by_tier" in body and settings_svc.user_tier != "admin":
                return JSONResponse(
                    content={"ok": False, "message": "Admin only"},
                    status_code=403,
                )
            return settings_svc._save_settings(body)

    # ── API key management ─────────────────────────────────────────
    @app.get("/api/settings/api-keys")
    def _api_keys_get() -> dict:
        """Return masked API keys for display."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import get_config_value
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            openai_key = get_config_value("openai_api_key", dr) or ""
            anthropic_key = get_config_value("anthropic_api_key", dr) or ""
            ollama_url = get_config_value("ollama_base_url", dr) or ""
            ollama_model = get_config_value("ollama_model", dr) or ""
            pocket_key = get_config_value("pocket_api_key", dr) or ""

            def mask(k: str) -> str:
                if not k or len(k) < 8:
                    return "***" if k else ""
                return k[:4] + "..." + k[-4:]

            return {
                "openai_api_key": mask(openai_key),
                "anthropic_api_key": mask(anthropic_key),
                "ollama_base_url": ollama_url,
                "ollama_model": ollama_model,
                "pocket_api_key": mask(pocket_key),
                "has_openai": bool(openai_key),
                "has_anthropic": bool(anthropic_key),
                "has_ollama": bool(ollama_url),
                "has_pocket": bool(pocket_key),
            }
        except Exception as exc:
            return {"error": str(exc)}

    @app.post("/api/settings/api-keys")
    async def _api_keys_post(request: Request) -> dict:
        """Update API keys. Requires app restart to take effect."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import set_config_value
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            body = await _parse_json_body(request)
            updated = []
            for key in (
                "openai_api_key",
                "anthropic_api_key",
                "ollama_base_url",
                "ollama_model",
                "pocket_api_key",
            ):
                if key in body and body[key] is not None:
                    val = body[key].strip()
                    if val and not val.startswith("***"):  # Don't save masked values
                        set_config_value(key, val, dr)
                        updated.append(key)
            return {"ok": True, "updated": updated, "restart_required": bool(updated)}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}

    # ── MCP API key generation ─────────────────────────────────────
    @app.get("/api/settings/mcp-key")
    def _api_mcp_key_get() -> dict:
        """Return masked MCP key if set."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import get_config_value
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            mcp_key = get_config_value("mcp_api_key", dr) or ""
            if mcp_key:
                masked = mcp_key[:8] + "..." + mcp_key[-4:] if len(mcp_key) > 12 else "***"
                return {"has_key": True, "masked_key": masked}
            return {"has_key": False, "masked_key": ""}
        except Exception as exc:
            return {"error": str(exc)}

    @app.post("/api/settings/mcp-key/generate")
    def _api_mcp_key_generate() -> dict:
        """Generate a new MCP API key."""
        import secrets

        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import set_config_value
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            new_key = "vst_mcp_" + secrets.token_urlsafe(32)
            set_config_value("mcp_api_key", new_key, dr)
            return {
                "ok": True,
                "key": new_key,
                "message": "Copy this key now. It won't be shown again in full.",
            }
        except Exception as exc:
            return {"ok": False, "error": str(exc)}

    # ── Mercury Banking (user's local installation only) ─────────────
    @app.get("/api/settings/mercury")
    def _api_mercury_get() -> dict:
        """Return Mercury config for Settings UI. Token masked. Up to 10 accounts."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import load_settings_json
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            stored = load_settings_json(dr)
            token = (stored.get("mercury_api_token") or "").strip()
            out = {
                "has_token": bool(token),
                "mercury_api_token": (token[:6] + "..." + token[-4:]) if len(token) > 10 else "***",
                "mercury_account_id": stored.get("mercury_account_id") or "",
                "mercury_account_name": stored.get("mercury_account_name") or "",
            }
            for i in range(1, 10):
                out[f"mercury_ro_{i}"] = stored.get(f"mercury_ro_{i}") or ""
                out[f"mercury_rn_{i}"] = stored.get(f"mercury_rn_{i}") or ""
            return out
        except Exception as exc:
            return {"error": str(exc)}

    @app.post("/api/settings/mercury")
    async def _api_mercury_post(request: Request) -> dict:
        """Save Mercury config to settings.json. Restart required to apply."""
        project_root = app.state.services.get("project_root") or Path.cwd()
        try:
            from vastian.config_store import load_settings_json, save_settings_json
            from vastian.storage.data_root import get_data_root

            dr = get_data_root(project_root)
            body = await _parse_json_body(request)
            mercury_keys = [
                "mercury_api_token",
                "mercury_account_id",
                "mercury_account_name",
                "mercury_ro_1",
                "mercury_ro_2",
                "mercury_ro_3",
                "mercury_ro_4",
                "mercury_ro_5",
                "mercury_ro_6",
                "mercury_ro_7",
                "mercury_ro_8",
                "mercury_ro_9",
                "mercury_rn_1",
                "mercury_rn_2",
                "mercury_rn_3",
                "mercury_rn_4",
                "mercury_rn_5",
                "mercury_rn_6",
                "mercury_rn_7",
                "mercury_rn_8",
                "mercury_rn_9",
            ]
            existing = load_settings_json(dr)
            for key in mercury_keys:
                if key in body:
                    val = body[key]
                    existing[key] = (val.strip() if isinstance(val, str) else "") or ""
            save_settings_json(dr, existing)
            return {"ok": True, "message": "Mercury settings saved. Restart Vastian to connect."}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}

    # ── Version endpoint ───────────────────────────────────────────
    @app.get("/api/version")
    def _api_version() -> dict:
        """Return app version for reload detection."""
        # Use git commit hash if available, else timestamp of this module
        version_hash = "dev"
        try:
            import subprocess

            result = subprocess.run(
                ["git", "rev-parse", "--short", "HEAD"],
                capture_output=True,
                text=True,
                timeout=3,
                cwd=str(app.state.services.get("project_root") or Path.cwd()),
            )
            if result.returncode == 0:
                version_hash = result.stdout.strip()
        except Exception:
            pass
        return {"version": "1.1.0", "build": version_hash}

    # ── Backup panel at /backup (optional, when project_root in services) ─
    project_root = app.state.services.get("project_root")
    user_tier = app.state.services.get("user_tier", "open")
    if project_root is not None:

        def _get_backup_panel():
            if not hasattr(_get_backup_panel, "_panel"):
                from vastian.tasks.backup_panel import BackupPanel

                _get_backup_panel._panel = BackupPanel(
                    project_root=project_root,
                    user_tier=user_tier,
                )
            return _get_backup_panel._panel

        @app.get("/backup", response_class=HTMLResponse)
        def _backup_html() -> str:
            return _get_backup_panel().render_html()

        @app.get("/api/backup/status")
        def _api_backup_status() -> dict:
            return _get_backup_panel().backup_manager.get_backup_status()

        @app.get("/api/backup/history")
        def _api_backup_history() -> dict:
            return {"backups": _get_backup_panel().backup_manager.list_backups()}

        @app.get("/api/config/snapshots")
        def _api_config_snapshots(file: str = "") -> dict:
            return {"snapshots": _get_backup_panel().backup_manager.list_config_snapshots(file)}

        @app.get("/api/backup/scan")
        def _api_backup_scan() -> dict:
            try:
                sc, uc, total = _get_backup_panel().get_file_counts()
                return {"total": total, "system_count": sc, "user_count": uc}
            except Exception as e:
                return {"total": 0, "system_count": 0, "user_count": 0, "error": str(e)}

        @app.get("/api/backup/verify")
        def _api_backup_verify() -> dict:
            from concurrent.futures import ThreadPoolExecutor

            from vastian.tasks.backup import create_backup_manager

            panel = _get_backup_panel()
            panel.backup_manager = create_backup_manager(panel.project_root)
            mgr = panel.backup_manager
            if not mgr.provider:
                return {
                    "ok": False,
                    "provider": mgr.config.provider,
                    "message": "No storage provider configured or failed to load.",
                }
            try:

                def _list() -> Any:
                    loop = asyncio.new_event_loop()
                    asyncio.set_event_loop(loop)
                    try:
                        return loop.run_until_complete(mgr.provider.list_files(prefix=""))
                    finally:
                        loop.close()

                with ThreadPoolExecutor(max_workers=1) as pool:
                    listed = pool.submit(_list).result()
                count = len(listed) if isinstance(listed, list) else 0
                sample = listed[:3] if listed else []
                names = [f.get("remote_path", f.get("name", "?")) for f in sample]
                return {
                    "ok": True,
                    "provider": mgr.config.provider,
                    "message": f"{count} backup(s) found. Sample: {', '.join(names) or 'none'}",
                }
            except Exception as e:
                return {"ok": False, "provider": mgr.config.provider, "message": str(e)}

        @app.post("/api/backup/trigger")
        async def _api_backup_trigger(request: Request) -> dict:

            from vastian.tasks.backup import create_backup_manager

            body = await _parse_json_body(request)
            force_full = body.get("force_full", True)
            panel = _get_backup_panel()
            panel.backup_manager = create_backup_manager(panel.project_root)
            panel.config = panel.backup_manager.config
            mgr = panel.backup_manager

            def _run() -> dict:
                import signal
                import sys

                files = mgr.scan() if hasattr(mgr, "scan") else []
                orig = getattr(signal, "set_wakeup_fd", None)
                if sys.platform == "win32" and orig:
                    asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
                    signal.set_wakeup_fd = lambda fd: -1
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                try:
                    return loop.run_until_complete(mgr.upload(files, force_full=force_full))
                finally:
                    with suppress(ValueError):
                        loop.close()
                    if sys.platform == "win32" and orig:
                        signal.set_wakeup_fd = orig

            result = await asyncio.get_event_loop().run_in_executor(None, _run)
            return {"status": "completed", **result}

        @app.post("/api/backup/restore")
        async def _api_backup_restore(request: Request) -> dict:

            body = await _parse_json_body(request)
            backup_id = body.get("backup_id", "")
            panel = _get_backup_panel()
            mgr = panel.backup_manager

            def _run() -> dict:
                import signal
                import sys

                orig = getattr(signal, "set_wakeup_fd", None)
                if sys.platform == "win32" and orig:
                    asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
                    signal.set_wakeup_fd = lambda fd: -1
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                try:
                    return loop.run_until_complete(mgr.restore([backup_id]))
                finally:
                    with suppress(ValueError):
                        loop.close()
                    if sys.platform == "win32" and orig:
                        signal.set_wakeup_fd = orig

            result = await asyncio.get_event_loop().run_in_executor(None, _run)
            return {"status": "restored", **result}

        @app.post("/api/backup/settings")
        async def _api_backup_settings(request: Request) -> dict:
            import yaml

            body = await _parse_json_body(request)
            panel = _get_backup_panel()
            if "schedule" in body and not panel.config.is_feature_allowed(
                "scheduled", panel.user_tier
            ):
                return JSONResponse(
                    content={"error": "Tier too low for schedule settings"}, status_code=403
                )
            from vastian.storage.data_root import get_config_dir

            path = get_config_dir(panel.project_root) / "backup" / "settings.yaml"
            path.parent.mkdir(parents=True, exist_ok=True)
            current = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {}
            if current is None:
                current = {}
            current.update(body)
            path.write_text(yaml.dump(current, default_flow_style=False), encoding="utf-8")
            if "provider" in body or "backup_mode" in body or "replace_previous" in body:
                from vastian.tasks.backup import create_backup_manager

                panel.backup_manager = create_backup_manager(panel.project_root)
                panel.config = panel.backup_manager.config
            return {"status": "updated"}

        @app.post("/api/config/save")
        async def _api_config_save(request: Request) -> dict:
            body = await _parse_json_body(request)
            cfg_file = body.get("file", "")
            content = body.get("content", "")
            panel = _get_backup_panel()
            mgr = panel.backup_manager
            with suppress(FileNotFoundError):
                mgr.save_config_snapshot(cfg_file)
            if content:
                from vastian.storage.data_root import get_config_dir

                cfg_dir = get_config_dir(panel.project_root)
                target = (
                    cfg_dir / cfg_file.removeprefix("config/")
                    if cfg_file.startswith("config/")
                    else panel.project_root / cfg_file
                )
                target.parent.mkdir(parents=True, exist_ok=True)
                target.write_text(content, encoding="utf-8")
            return {"status": "saved"}

        @app.post("/api/config/restore")
        async def _api_config_restore(request: Request) -> dict:
            body = await _parse_json_body(request)
            cfg_file = body.get("file", "")
            snap_id = body.get("snapshot_id")
            panel = _get_backup_panel()
            try:
                panel.backup_manager.restore_config_snapshot(cfg_file, snap_id)
                return {"status": "restored"}
            except FileNotFoundError as e:
                return JSONResponse(content={"error": str(e)}, status_code=404)

        @app.post("/api/config/reset")
        async def _api_config_reset(request: Request) -> dict:
            body = await _parse_json_body(request)
            cfg_file = body.get("file", "")
            panel = _get_backup_panel()
            try:
                panel.backup_manager.reset_config_to_default(cfg_file)
                return {"status": "reset"}
            except FileNotFoundError as e:
                return JSONResponse(content={"error": str(e)}, status_code=404)

    # ── MCP JSON-RPC (Tauri single-port) ─────────────────────────────────
    try:
        from vastian.mcp.server import _handle_json_rpc, _validate_auth

        @app.post("/mcp")
        async def _mcp_post(request: Request):
            auth = request.headers.get("Authorization")
            if not _validate_auth(auth):
                return JSONResponse(
                    content={"error": "Unauthorized"},
                    status_code=401,
                )
            try:
                body = await request.json()
            except Exception as e:
                return JSONResponse(
                    content={"error": str(e)},
                    status_code=400,
                )
            response = _handle_json_rpc(body)
            if response is None:
                return JSONResponse(content={}, status_code=202)
            return JSONResponse(content=response)

        @app.options("/mcp")
        async def _mcp_options():
            from fastapi.responses import Response

            return Response(
                status_code=204,
                headers={
                    "Access-Control-Allow-Origin": "*",
                    "Access-Control-Allow-Methods": "POST, OPTIONS",
                    "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version",
                },
            )
    except ImportError:
        pass  # mcp optional

    return app


def run_server(
    app: FastAPI,
    port: int = 4545,
    host: str = "127.0.0.1",
) -> str:
    """Run uvicorn in a daemon thread. Returns base URL. Tries port fallback if 4545 is in use."""
    import uvicorn

    actual_port = find_available_port(host, port)

    def _run() -> None:
        uvicorn.run(
            app,
            host=host,
            port=actual_port,
            log_level="warning",
        )

    thread = threading.Thread(target=_run, daemon=True, name="vastian-server")
    thread.start()
    url = f"http://{host}:{actual_port}"
    logger.info("Unified server started at %s", url)
    return url


def run_server_blocking(
    app: FastAPI,
    port: int = 4545,
    host: str = "127.0.0.1",
    timeout_graceful_shutdown: float = 10.0,
) -> None:
    """Run uvicorn in main thread. Handles SIGTERM gracefully for Tauri sidecar. Tries port fallback if 4545 is in use."""
    import uvicorn

    actual_port = find_available_port(host, port)
    config = uvicorn.Config(
        app,
        host=host,
        port=actual_port,
        log_level="warning",
        timeout_graceful_shutdown=timeout_graceful_shutdown,
    )
    server = uvicorn.Server(config)
    server.run()


async def run_server_async(
    app: FastAPI,
    port: int = 4545,
    host: str = "127.0.0.1",
    timeout_graceful_shutdown: float = 10.0,
) -> None:
    """Run uvicorn async (await server.serve()). Tries port fallback if 4545 is in use."""
    import uvicorn

    actual_port = find_available_port(host, port)
    config = uvicorn.Config(
        app,
        host=host,
        port=actual_port,
        log_level="warning",
        timeout_graceful_shutdown=timeout_graceful_shutdown,
    )
    server = uvicorn.Server(config)
    await server.serve()
